diff --git a/api/services/ai/models.ts b/api/services/ai/models.ts index 30eb7fd..ce8dcf0 100644 --- a/api/services/ai/models.ts +++ b/api/services/ai/models.ts @@ -3,5 +3,5 @@ import { Models } from './types' export const models: Record = { openai: 'o4-mini-2025-04-16', gemini: 'gemini-2.5-flash', - claude: 'claude-4-sonnet', + claude: 'claude-3-5-sonnet-20241022', } diff --git a/api/steps/chess/03-retry-last-move-api.step.ts b/api/steps/chess/03-retry-last-move-api.step.ts new file mode 100644 index 0000000..696c196 --- /dev/null +++ b/api/steps/chess/03-retry-last-move-api.step.ts @@ -0,0 +1,62 @@ +import { ApiRouteConfig, Handlers } from 'motia' +import { z } from 'zod' + +export const config: ApiRouteConfig = { + type: 'api', + name: 'RetryLastMove', + description: 'Retry last move', + path: '/chess/game/:id/retry-last-move', + method: 'POST', + emits: ['chess-game-moved', 'chess-game-ended'], + flows: ['chess'], + bodySchema: z.object({}), + responseSchema: { + 200: z.object({ + message: z.string(), + }), + 404: z.object({ message: z.string() }), + 400: z.object({ message: z.string() }), + }, +} + +export const handler: Handlers['RetryLastMove'] = async (req, { logger, emit, streams, state }) => { + logger.info('Received retry last move request', { body: req.body }) + + const gameId = req.pathParams.id + const game = await streams.chessGame.get('game', gameId) + + if (!game) { + return { status: 404, body: { message: 'Game not found' } } + } else if (game.status === 'completed') { + return { status: 400, body: { message: 'Game is finished' } } + } else if (!game.players[game.turn].ai) { + return { status: 400, body: { message: 'Cannot retry last unless AI is playing' } } + } + + const messageId = crypto.randomUUID() + + const player = game.turn === 'white' ? game.players.white : game.players.black + if (!!player.ai) { + await streams.chessGameMessage.set(gameId, messageId, { + message: 'Retrying move...', + sender: player.ai, + role: game.turn, + timestamp: Date.now(), + }) + } + + await streams.chessGame.set('game', game.id, { + ...game, + status: 'pending', + }) + + await emit({ + topic: 'chess-game-moved', + data: { + gameId, + fenBefore: game.fen, + }, + }) + + return { status: 200, body: { message: 'Last move retried' } } +} \ No newline at end of file diff --git a/api/steps/chess/05-ai-player.step.ts b/api/steps/chess/05-ai-player.step.ts index aefc15f..74188fa 100644 --- a/api/steps/chess/05-ai-player.step.ts +++ b/api/steps/chess/05-ai-player.step.ts @@ -8,6 +8,7 @@ import { evaluateBestMoves } from '../../services/chess/evaluate-best-moves' import { ActionMove, move } from '../../services/chess/move' const MAX_ATTEMPTS = 3 +const MAX_AI_RETRY_MOVE_ATTEMPTS = 3; export const config: EventConfig = { type: 'event', @@ -64,8 +65,6 @@ export const handler: Handlers['AI_Player'] = async (input, { logger, emit, stre const validMoves = evaluateBestMoves(game) let lastInvalidMove = undefined - logger.info('Valid moves', { validMoves }) - while (true) { const messageId = crypto.randomUUID() @@ -95,10 +94,10 @@ export const handler: Handlers['AI_Player'] = async (input, { logger, emit, stre let action: { thought: string; move: ActionMove } | undefined try { - logger.info('Prompt', { prompt }) action = await makePrompt(prompt, responseSchema, player.ai, logger) logger.info('Updating message', { messageId, gameId: input.gameId }) + await streams.chessGameMessage.set(input.gameId, messageId, { ...message, message: action.thought, @@ -110,6 +109,33 @@ export const handler: Handlers['AI_Player'] = async (input, { logger, emit, stre message: 'Error making prompt, I will need to try again soon', }) + const nextRetryMoveAttempts = (game.players[input.player].retryMoveAttempts ?? 0) + 1 + + if (nextRetryMoveAttempts > MAX_AI_RETRY_MOVE_ATTEMPTS) { + await streams.chessGame.set('game', game.id, { + ...game, + status: 'completed', + endGameReason: 'Reached max retry attempts for AI player move', + }) + + await emit({ + topic: 'chess-game-ended', + data: { gameId: game.id }, + }) + } else { + await streams.chessGame.set('game', game.id, { + ...game, + status: 'requires-retry', + players: { + ...game.players, + [input.player]: { + ...game.players[input.player], + retryMoveAttempts: nextRetryMoveAttempts, + }, + } + }) + } + logger.error('Error making prompt', { err }) throw err } diff --git a/api/steps/chess/streams/00-chess-game.stream.ts b/api/steps/chess/streams/00-chess-game.stream.ts index 08d3e2d..e304de1 100644 --- a/api/steps/chess/streams/00-chess-game.stream.ts +++ b/api/steps/chess/streams/00-chess-game.stream.ts @@ -32,7 +32,7 @@ export const gameSchema = z.object({ id: z.string({ description: 'The ID of the game' }), fen: z.string({ description: 'The FEN of the game' }), turn: z.enum(['white', 'black'], { description: 'The color of the current turn' }), - status: z.enum(['pending', 'completed', 'draw'], { description: 'The status of the game' }), + status: z.enum(['pending', 'completed', 'draw', 'requires-retry'], { description: 'The status of the game' }), lastMove: z.array(z.string({ description: 'The last move made' })).optional(), winner: z.enum(['white', 'black']).optional(), turns: z.number({ description: 'The number of turns' }).optional(), @@ -52,6 +52,7 @@ export const gameSchema = z.object({ ) .optional(), promotions: z.number({ description: 'The number of pawn promotions' }).optional(), + retryMoveAttempts: z.number({ description: 'The number of retry move attempts' }).optional(), }), black: z.object({ name: z.string({ description: 'The name of the player' }), @@ -67,6 +68,7 @@ export const gameSchema = z.object({ ) .optional(), promotions: z.number({ description: 'The number of pawn promotions' }).optional(), + retryMoveAttempts: z.number({ description: 'The number of retry move attempts' }).optional(), }), }), check: z.boolean({ description: 'Whether the game is in check' }), diff --git a/api/types.d.ts b/api/types.d.ts index bad5678..452b905 100644 --- a/api/types.d.ts +++ b/api/types.d.ts @@ -11,7 +11,7 @@ declare module 'motia' { 'chessSidechatMessage': MotiaStream<{ message: string; sender: string; role: 'white' | 'black' | 'spectator' | 'root'; timestamp: number }> 'chessLiveAiGames': MotiaStream<{ id: string; gameId: string; players: { white: string; black: string } }> 'chessLeaderboard': MotiaStream<{ provider: 'openai' | 'gemini' | 'claude'; model: string; gamesPlayed: number; victories: number; checkmates: number; draws: number; illegalMoves: number; sumCentipawnScores: number; sumHighestSwing: number }> - 'chessGame': MotiaStream<{ id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> + 'chessGame': MotiaStream<{ id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw' | 'requires-retry'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number; retryMoveAttempts?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number; retryMoveAttempts?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> 'chessGameMove': MotiaStream<{ color: 'white' | 'black'; fenBefore: string; fenAfter: string; lastMove: string[]; check: boolean; evaluation?: { centipawnScore: number; bestMove: string; evaluationSwing: number; blunder: boolean } }> 'chessGameMessage': MotiaStream<{ message: string; sender: string; role: 'white' | 'black' | 'spectator' | 'root'; timestamp: number; move?: { from: string; to: string; promotion?: 'q' | 'r' | 'b' | 'n' }; isIllegalMove?: boolean }> } @@ -22,9 +22,10 @@ declare module 'motia' { 'SendMessage': ApiRouteHandler<{ message: string; name: string; role: 'white' | 'black' | 'spectator' | 'root' }, ApiResponse<200, { message: string; sender: string; timestamp: number }> | ApiResponse<404, { message: string }>, never> 'AI_Player': EventHandler<{ player: 'white' | 'black'; fenBefore: string; fen: string; lastMove?: string[]; check: boolean; gameId: string }, { topic: 'chess-game-moved'; data: { gameId: string; fenBefore: string } } | { topic: 'chess-game-ended'; data: { gameId: string } } | { topic: 'evaluate-player-move'; data: { fenBefore: string; fenAfter: string; gameId: string; moveId: string; player: string } }> 'ChessGameMoved': EventHandler<{ gameId: string; fenBefore: string }, { topic: 'ai-move'; data: { player: 'white' | 'black'; fenBefore: string; fen: string; lastMove?: string[]; check: boolean; gameId: string } }> - 'MovePiece': ApiRouteHandler<{ password: string; promote?: 'queen' | 'rook' | 'bishop' | 'knight'; from: string; to: string }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string }> | ApiResponse<404, { message: string }>, { topic: 'chess-game-moved'; data: { gameId: string; fenBefore: string } } | { topic: 'chess-game-ended'; data: { gameId: string } } | { topic: 'evaluate-player-move'; data: { fenBefore: string; fenAfter: string; gameId: string; moveId: string; player: string } }> - 'GetGame': ApiRouteHandler<{}, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } }; role: 'white' | 'black' | 'spectator' | 'root'; username: string; passwords?: { root: string; white: string; black: string } }> | ApiResponse<404, { message: string }>, never> - 'GetLiveAiGame': ApiRouteHandler<{ players: 'openai' | 'gemini' | 'claude'[] }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string; errors?: { message: string }[] }> | ApiResponse<404, { message: string }>, { topic: 'chess-game-created'; data: { gameId: string; fenBefore: string } }> - 'CreateGame': ApiRouteHandler<{ players: { white: { name: string }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude' } } }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string; errors: { message: string }[] }>, { topic: 'chess-game-created'; data: { gameId: string; fenBefore: string } }> + 'MovePiece': ApiRouteHandler<{ password: string; promote?: 'queen' | 'rook' | 'bishop' | 'knight'; from: string; to: string }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw' | 'requires-retry'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string }> | ApiResponse<404, { message: string }>, { topic: 'chess-game-moved'; data: { gameId: string; fenBefore: string } } | { topic: 'chess-game-ended'; data: { gameId: string } } | { topic: 'evaluate-player-move'; data: { fenBefore: string; fenAfter: string; gameId: string; moveId: string; player: string } }> + 'GetGame': ApiRouteHandler<{}, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw' | 'requires-retry'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } }; role: 'white' | 'black' | 'spectator' | 'root'; username: string; passwords?: { root: string; white: string; black: string } }> | ApiResponse<404, { message: string }>, never> + 'GetLiveAiGame': ApiRouteHandler<{ players: 'openai' | 'gemini' | 'claude'[] }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw' | 'requires-retry'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string; errors?: { message: string }[] }> | ApiResponse<404, { message: string }>, { topic: 'chess-game-created'; data: { gameId: string; fenBefore: string } }> + 'CreateGame': ApiRouteHandler<{ players: { white: { name: string }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude' } } }, ApiResponse<200, { id: string; fen: string; turn: 'white' | 'black'; status: 'pending' | 'completed' | 'draw' | 'requires-retry'; lastMove?: string[]; winner?: 'white' | 'black'; turns?: number; endGameReason?: string; players: { white: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number }; black: { name: string; ai?: 'openai' | 'gemini' | 'claude'; illegalMoveAttempts?: number; totalMoves?: number; captures?: { piece: string; score: number }[]; promotions?: number } }; check: boolean; scoreboard?: { white: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; black: { averageSwing: number; medianSwing: number; highestSwing: number; highestCentipawnScore: number; lowestCentipawnScore: number; averageCentipawnScore: number; medianCentipawnScore: number; finalCentipawnScore: number; blunders: number }; totalMoves: number; decisiveMoment?: { moveNumber: number; evaluationSwing: number; move: string[]; fen: string } } }> | ApiResponse<400, { message: string; errors: { message: string }[] }>, { topic: 'chess-game-created'; data: { gameId: string; fenBefore: string } }> + 'RetryLastMove': ApiRouteHandler<{}, ApiResponse<200, { message: string }> | ApiResponse<400, { message: string }> | ApiResponse<404, { message: string }>, { topic: 'chess-game-moved'; data: { gameId: string; fenBefore: string } } | { topic: 'chess-game-ended'; data: { gameId: string } }> } } \ No newline at end of file diff --git a/app/src/components/chess/chess-board.tsx b/app/src/components/chess/chess-board.tsx index 067a652..a8fe1c2 100644 --- a/app/src/components/chess/chess-board.tsx +++ b/app/src/components/chess/chess-board.tsx @@ -1,6 +1,6 @@ import type { Game, GameRole } from '@/lib/types' import { useChessInstance } from '@/lib/use-chess-instance' -import { useMove } from '@/lib/use-move' +import { useMove, useRetryMove } from '@/lib/use-move' import { Chess, SQUARES, type Square } from 'chess.js' import type { Config } from 'chessground/config' import type { Key, Role } from 'chessground/types' @@ -8,6 +8,8 @@ import { useEffect, useState } from 'react' import { Chessground } from './chessground' import { ChessPromote } from './promote/chess-promote' import { ChessSound } from './chess-sound' +import { toast } from 'sonner' +import { ChessRetryMove } from './chess-retry-move' export function toDests(chess: Chess): Map { const dests = new Map() @@ -36,6 +38,7 @@ export const ChessBoard: React.FC = ({ password, role, game }) => { const { getInstance } = useChessInstance() const move = useMove({ gameId: game.id }) + const retryMove = useRetryMove({ gameId: game.id }) const [moves, setMoves] = useState>(new Map()) const [promote, setPromote] = useState() @@ -48,8 +51,14 @@ export const ChessBoard: React.FC = ({ password, role, game }) => { } }, [game?.fen]) - if (!game) { - return + useEffect(() => { + console.log("game?.status", game?.status) + }, [game?.status]) + + const handleRetryMove = () => { + retryMove().catch(() => { + toast.error('Failed to retry move') + }) } const onPromote = (piece: Role) => { @@ -59,6 +68,10 @@ export const ChessBoard: React.FC = ({ password, role, game }) => { setPromote(undefined) } + if (!game) { + return + } + // define based on the role const color = role === 'white' ? 'white' : role === 'black' ? 'black' : role === 'root' ? game.turn : undefined @@ -93,6 +106,7 @@ export const ChessBoard: React.FC = ({ password, role, game }) => { + {game.status === 'requires-retry' && handleRetryMove()} />} ) } diff --git a/app/src/components/chess/chess-retry-move.tsx b/app/src/components/chess/chess-retry-move.tsx new file mode 100644 index 0000000..e6a4611 --- /dev/null +++ b/app/src/components/chess/chess-retry-move.tsx @@ -0,0 +1,40 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { RotateCcw } from 'lucide-react' +import { useState } from 'react' + +export const ChessRetryMove = ({ onClick }: { onClick: () => void }) => { + const [isOpen, setIsOpen] = useState(true) + + const onRetryClick = () => { + onClick(); + setIsOpen(false); + } + return ( + + + + Retry last move + +
+

Oops! Something went wrong, would you like to retry the last move?

+ +
+
+
+ ) +} \ No newline at end of file diff --git a/app/src/lib/types.d.ts b/app/src/lib/types.d.ts index 720fdff..ad78da0 100644 --- a/app/src/lib/types.d.ts +++ b/app/src/lib/types.d.ts @@ -24,7 +24,7 @@ export type Game = { turn: 'white' | 'black' endGameReason?: string winner?: 'white' | 'black' - status: 'created' | 'pending' | 'completed' | 'draw' + status: 'created' | 'pending' | 'completed' | 'draw' | 'requires-retry' lastMove: Key[] players: { white: Player; black: Player } scoreboard?: Scoreboard diff --git a/app/src/lib/use-move.ts b/app/src/lib/use-move.ts index db9e61e..ed29890 100644 --- a/app/src/lib/use-move.ts +++ b/app/src/lib/use-move.ts @@ -24,3 +24,18 @@ export const useMove = ({ gameId }: Args) => { return move } + + +export const useRetryMove = ({ gameId }: Args) => { + const retryMove = useCallback(async () => { + const res = await fetch(`${apiUrl}/chess/game/${gameId}/retry-last-move`, { + method: 'POST', + body: JSON.stringify({}), + headers: { 'Content-Type': 'application/json' }, + }) + + return res.ok + }, []) + + return retryMove +} \ No newline at end of file