diff --git a/LOG.md b/LOG.md index 5750ab3..6cee799 100644 --- a/LOG.md +++ b/LOG.md @@ -2,6 +2,18 @@ ## 2026-06-19 +- Added Maia game resign and undo API wrappers, then wired the play screen with in-progress-only "무르기" and "기권" actions plus a persistent "새 게임" link back to the setup screen. +- Locked board/actions during move, undo, and resign requests, updated the game cache from backend responses as the single source of truth, and refreshed history/stat queries after successful state changes. +- Added resigned-loss labeling so play, recent games, and history can show `기권패` for `RESIGNED` losses while still grouping them under losses. +- Validation: `npm run lint` passed and `npm run build` passed. Authenticated browser verification against the live Maia backend was not run in this session. +- Recommended next task: test resign and undo with an authenticated real game, including black-player games where Maia's opening white move must remain after undo. + +- Reworked the shared chess board UI with a bordered board surface, high-contrast chip-style pieces, stronger selected/legal/last-move highlights, and transform-based piece movement transitions so player and Maia moves read as motion instead of instant jumps. +- Connected the daily puzzle board to the shared `ChessBoard` component, removing duplicated piece rendering and adding drag/drop support for puzzle moves. +- Updated Maia play state so optimistic player moves are highlighted locally while pending, then Maia's returned move can animate from the server response; changed the pending overlay into a small status pill so it no longer hides the board. +- Validation: `npm run lint` passed and `npm run build` passed. A local dev server is running on `http://localhost:3124`; browser verification with a temporary local puzzle API confirmed 64 squares, 32 piece-layer nodes, 0.42s piece transition styling, square selection/legal target markers, and no console errors. The temporary API was stopped after verification. +- Recommended next task: verify `/chess/play/{gameId}` against the real Maia backend with an authenticated account to tune the exact Maia response timing and capture/promotion edge cases. + - Added the authenticated Maia chess frontend: `/chess` lobby with rating/color/model and advanced settings, `/chess/play/[gameId]` playable board with backend FEN synchronization and PGN copy, and `/chess/history` account game history with outcome filters. - Extended the chess API client and shared types for Maia game create/list/stats/detail/PGN/move endpoints while leaving the public `/api/chess-puzzles/**` puzzle request unchanged. - Updated the Dock/menu/shell route handling so Chess opens the Maia lobby, with the existing public puzzle page still available at `/play/chess`. diff --git a/src/api/chess.ts b/src/api/chess.ts index 050253d..ea66a5d 100644 --- a/src/api/chess.ts +++ b/src/api/chess.ts @@ -71,3 +71,15 @@ export const postChessMove = async (gameId: string, data: ChessMoveRequest) => { return requireApiData(response.data, '착수를 처리하지 못했습니다.'); }; + +export const resignChessGame = async (gameId: string) => { + const response = await http.post>(`/api/chess/games/${gameId}/resign`); + + return requireApiData(response.data, '기권을 처리하지 못했습니다.'); +}; + +export const undoChessMove = async (gameId: string) => { + const response = await http.post>(`/api/chess/games/${gameId}/undo`); + + return requireApiData(response.data, '무르기를 처리하지 못했습니다.'); +}; diff --git a/src/components/chess/ChessBoard.tsx b/src/components/chess/ChessBoard.tsx index 9de4572..8170d03 100644 --- a/src/components/chess/ChessBoard.tsx +++ b/src/components/chess/ChessBoard.tsx @@ -1,15 +1,25 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { Chess as ChessGame, type Color, type PieceSymbol, type Square } from 'chess.js'; import { clsx } from 'clsx'; import { ChessColor } from '@/types'; -type MoveSquares = { +export type MoveSquares = { from: Square; to: Square; }; +type BoardPiece = { + square: Square; + color: Color; + type: PieceSymbol; +}; + +type VisualPiece = BoardPiece & { + id: string; +}; + interface ChessBoardProps { fen: string; orientation?: ChessColor; @@ -26,6 +36,10 @@ const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const; const WHITE_RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const; const BLACK_RANKS = [1, 2, 3, 4, 5, 6, 7, 8] as const; +export const BOARD_SQUARES = WHITE_RANKS.flatMap((rank) => + FILES.map((file) => `${file}${rank}` as Square), +); + const PIECE_SYMBOLS: Record> = { w: { k: '♔', @@ -71,6 +85,15 @@ const getBoardSquares = (orientation: ChessColor) => { return ranks.flatMap((rank) => files.map((file) => `${file}${rank}` as Square)); }; +const getPieces = (game: ChessGame): BoardPiece[] => + BOARD_SQUARES.flatMap((square) => { + const piece = game.get(square); + + if (!piece) return []; + + return [{ square, color: piece.color, type: piece.type }]; + }); + const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => { if (!piece) return `${square} 빈 칸`; @@ -84,6 +107,73 @@ const isLightSquare = (square: Square) => { return (fileIndex + rank) % 2 === 0; }; +const getSquarePosition = (square: Square, orientation: ChessColor): CSSProperties => { + const fileIndex = FILES.indexOf(square[0] as (typeof FILES)[number]); + const rank = Number(square[1]); + const x = orientation === 'black' ? 7 - fileIndex : fileIndex; + const y = orientation === 'black' ? rank - 1 : 8 - rank; + + return { + transform: `translate(${x * 100}%, ${y * 100}%)`, + }; +}; + +const samePiece = (a: BoardPiece, b: BoardPiece) => a.color === b.color && a.type === b.type; + +const canRepresentMove = (fromPiece: VisualPiece, toPiece: BoardPiece) => + fromPiece.color === toPiece.color && (fromPiece.type === toPiece.type || fromPiece.type === 'p'); + +const createPieceId = (piece: BoardPiece, index: number) => + `${piece.color}-${piece.type}-${piece.square}-${index}`; + +const createVisualPieces = (pieces: BoardPiece[]) => pieces.map((piece, index) => ({ ...piece, id: createPieceId(piece, index) })); + +const reconcileVisualPieces = ( + previousPieces: VisualPiece[], + nextPieces: BoardPiece[], + lastMoveSquares?: MoveSquares | null, +) => { + if (!lastMoveSquares) return createVisualPieces(nextPieces); + + const nextBySquare = new Map(nextPieces.map((piece) => [piece.square, piece])); + const movedPieceTarget = nextBySquare.get(lastMoveSquares.to); + + if (!movedPieceTarget) return createVisualPieces(nextPieces); + + const usedVisualIds = new Set(); + const usedSquares = new Set(); + const reconciled: VisualPiece[] = []; + const movingPiece = + previousPieces.find( + (piece) => piece.square === lastMoveSquares.from && canRepresentMove(piece, movedPieceTarget), + ) ?? previousPieces.find((piece) => piece.square === lastMoveSquares.from); + + if (movingPiece) { + reconciled.push({ ...movingPiece, ...movedPieceTarget }); + usedVisualIds.add(movingPiece.id); + usedSquares.add(movedPieceTarget.square); + } + + previousPieces.forEach((visualPiece) => { + if (usedVisualIds.has(visualPiece.id)) return; + + const nextPiece = nextBySquare.get(visualPiece.square); + if (!nextPiece || usedSquares.has(nextPiece.square) || !samePiece(visualPiece, nextPiece)) return; + + reconciled.push({ ...visualPiece, ...nextPiece }); + usedVisualIds.add(visualPiece.id); + usedSquares.add(nextPiece.square); + }); + + nextPieces.forEach((nextPiece, index) => { + if (usedSquares.has(nextPiece.square)) return; + + reconciled.push({ ...nextPiece, id: createPieceId(nextPiece, previousPieces.length + index) }); + }); + + return reconciled; +}; + export const getMoveSquares = (move?: string | null): MoveSquares | null => { if (!move || move.length < 4) return null; @@ -112,104 +202,146 @@ export default function ChessBoard({ const boardSquares = useMemo(() => getBoardSquares(orientation), [orientation]); const firstFile = orientation === 'black' ? 'h' : 'a'; const lastRank = orientation === 'black' ? '8' : '1'; + const [visualPieces, setVisualPieces] = useState(() => createVisualPieces(getPieces(game))); + const previousFenRef = useRef(fen); + + useEffect(() => { + const nextPieces = getPieces(game); + + setVisualPieces((previousPieces) => { + if (previousFenRef.current === fen) return previousPieces; + + previousFenRef.current = fen; + return reconcileVisualPieces(previousPieces, nextPieces, lastMoveSquares); + }); + }, [fen, game, lastMoveSquares]); return ( -
- {boardSquares.map((square) => { - const piece = game.get(square); - const isLight = isLightSquare(square); - const isSelected = selectedSquare === square; - const isTarget = legalTargets?.has(square) ?? false; - const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square; - const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true); - const file = square[0]; - const rank = square[1]; +
+
+
+ {boardSquares.map((square) => { + const piece = game.get(square); + const isLight = isLightSquare(square); + const isSelected = selectedSquare === square; + const isTarget = legalTargets?.has(square) ?? false; + const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square; + const canDrag = !disabled && Boolean(piece) && (isDraggableSquare?.(square) ?? true); + const file = square[0]; + const rank = square[1]; - return ( - + ); + })} +
+ +
+ {visualPieces.map((piece) => { + const canDrag = !disabled && (isDraggableSquare?.(piece.square) ?? true); + const isSelected = selectedSquare === piece.square; + const isLastMovePiece = lastMoveSquares?.to === piece.square; + + return ( + - )} - - ); - })} + ); + })} +
+
); } diff --git a/src/components/chess/ChessGamePlayClient.tsx b/src/components/chess/ChessGamePlayClient.tsx index 2ab3b7a..e11b269 100644 --- a/src/components/chess/ChessGamePlayClient.tsx +++ b/src/components/chess/ChessGamePlayClient.tsx @@ -9,20 +9,23 @@ import { AlertCircle, ChevronLeft, Clipboard, + Flag, History, Home, Loader2, + PlusCircle, RotateCcw, Swords, + Undo2, } from 'lucide-react'; import toast from 'react-hot-toast'; -import { getChessGame, postChessMove } from '@/api/chess'; -import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor } from '@/components/chess/ChessBoard'; -import { getChessErrorMessage, isAuthError, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi'; +import { getChessGame, postChessMove, resignChessGame, undoChessMove } from '@/api/chess'; +import ChessBoard, { getMoveSquares, getTurnLabel, toChessJsColor, type MoveSquares } from '@/components/chess/ChessBoard'; +import { getChessErrorMessage, getChessOutcomeLabel, isAuthError, outcomeBadgeTones } from '@/components/chess/chessUi'; import StatusBadge from '@/components/ui/StatusBadge'; import WindowSurface from '@/components/ui/WindowSurface'; import { useAuthStore } from '@/store/authStore'; -import { ChessGameResponse } from '@/types'; +import { ChessColor, ChessGameResponse } from '@/types'; interface ChessGamePlayClientProps { gameId: string; @@ -46,6 +49,14 @@ const getPreferredMove = (moves: Move[], to: Square) => { const toUciMove = (move: Move) => `${move.from}${move.to}${move.promotion ?? ''}`; +const hasPlayerMoved = (moves: string[], playerColor: ChessColor) => { + return moves.some((_, index) => { + const side = index % 2 === 0 ? 'white' : 'black'; + + return side === playerColor; + }); +}; + function ChessPageFrame({ children }: { children: ReactNode }) { return (
@@ -109,19 +120,27 @@ function GameInfoPanel({ game, isPlayerTurn, pending, + canUndo, + canResign, onCopyPgn, + onUndo, + onResign, }: { game: ChessGameResponse; isPlayerTurn: boolean; pending: boolean; + canUndo: boolean; + canResign: boolean; onCopyPgn: () => void; + onUndo: () => void; + onResign: () => void; }) { const gameEnded = game.status !== 'IN_PROGRESS'; return (
- {outcomeLabels[game.outcome]} + {getChessOutcomeLabel(game.outcome, game.status)} {game.result && {game.result}}
@@ -153,11 +172,43 @@ function GameInfoPanel({ )} > {gameEnded && '종료된 대국입니다.'} - {!gameEnded && pending && 'Maia 응답을 기다리는 중입니다.'} + {!gameEnded && pending && '요청을 처리하는 중입니다.'} {!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'} {!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
+
+ {game.status === 'IN_PROGRESS' && ( + <> + + + + )} + + + 새 게임 + +
+

마지막 Maia 수

@@ -190,6 +241,7 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps const { isLoggedIn, _hasHydrated } = useAuthStore(); const [selectedSquare, setSelectedSquare] = useState(null); const [optimisticFen, setOptimisticFen] = useState(null); + const [optimisticMoveSquares, setOptimisticMoveSquares] = useState(null); const gameQueryKey = useMemo(() => ['chess-game', gameId] as const, [gameId]); const gameQuery = useQuery({ @@ -199,26 +251,63 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps retry: 0, }); + const syncUpdatedGame = async (updatedGame: ChessGameResponse) => { + queryClient.setQueryData(gameQueryKey, updatedGame); + setOptimisticFen(null); + setOptimisticMoveSquares(null); + setSelectedSquare(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['chess-games'] }), + queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }), + ]); + }; + const moveMutation = useMutation({ mutationFn: (move: string) => postChessMove(gameId, { move }), onSuccess: async (updatedGame) => { - queryClient.setQueryData(gameQueryKey, updatedGame); - setOptimisticFen(null); - setSelectedSquare(null); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['chess-games'] }), - queryClient.invalidateQueries({ queryKey: ['chess-game-stats'] }), - ]); + await syncUpdatedGame(updatedGame); }, onError: (error) => { setOptimisticFen(null); + setOptimisticMoveSquares(null); setSelectedSquare(null); toast.error(getChessErrorMessage(error, '봇 응답을 가져오지 못했습니다. 잠시 후 다시 시도해주세요.')); void gameQuery.refetch(); }, }); + const undoMutation = useMutation({ + mutationFn: () => undoChessMove(gameId), + onSuccess: async (updatedGame) => { + await syncUpdatedGame(updatedGame); + toast.success('무르기를 적용했습니다.'); + }, + onError: (error) => { + setOptimisticFen(null); + setOptimisticMoveSquares(null); + setSelectedSquare(null); + toast.error(getChessErrorMessage(error, '무르기를 처리하지 못했습니다.')); + void gameQuery.refetch(); + }, + }); + + const resignMutation = useMutation({ + mutationFn: () => resignChessGame(gameId), + onSuccess: async (updatedGame) => { + await syncUpdatedGame(updatedGame); + toast.success('기권 처리되었습니다.'); + }, + onError: (error) => { + setOptimisticFen(null); + setOptimisticMoveSquares(null); + setSelectedSquare(null); + toast.error(getChessErrorMessage(error, '기권을 처리하지 못했습니다.')); + void gameQuery.refetch(); + }, + }); + const game = gameQuery.data; + const isPending = moveMutation.isPending || undoMutation.isPending || resignMutation.isPending; const currentFen = optimisticFen ?? game?.fen ?? INITIAL_FEN; const localGame = useMemo(() => createGame(currentFen), [currentFen]); const playerColor = game ? toChessJsColor(game.playerColor) : null; @@ -226,7 +315,7 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps Boolean(game && localGame && playerColor) && game?.status === 'IN_PROGRESS' && game.turn === game.playerColor && - !moveMutation.isPending; + !isPending; const legalMoves = useMemo(() => { if (!localGame || !selectedSquare || !canInteract) return []; @@ -235,7 +324,8 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps }, [canInteract, localGame, selectedSquare]); const legalTargets = useMemo(() => new Set(legalMoves.map((move) => move.to)), [legalMoves]); - const lastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]); + const serverLastMoveSquares = useMemo(() => getMoveSquares(game?.maiaMove ?? game?.moves.at(-1)), [game?.maiaMove, game?.moves]); + const lastMoveSquares = optimisticMoveSquares ?? serverLastMoveSquares; const isOwnTurnPiece = (square: Square) => { if (!localGame || !playerColor || !canInteract) return false; @@ -266,9 +356,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps }); setOptimisticFen(nextGame.fen()); + setOptimisticMoveSquares({ from, to }); setSelectedSquare(null); moveMutation.mutate(toUciMove(candidate)); } catch { + setOptimisticMoveSquares(null); toast.error('합법적인 수가 아닙니다.'); } }; @@ -313,6 +405,29 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps .catch(() => toast.error('PGN 복사에 실패했습니다.')); }; + const canResign = Boolean(game && game.status === 'IN_PROGRESS' && !isPending); + const canUndo = Boolean(game && game.status === 'IN_PROGRESS' && !isPending && hasPlayerMoved(game.moves, game.playerColor)); + const pendingLabel = resignMutation.isPending + ? '기권 처리 중' + : undoMutation.isPending + ? '무르기 처리 중' + : moveMutation.isPending + ? 'Maia 생각 중' + : ''; + + const handleUndo = () => { + if (!canUndo) return; + + undoMutation.mutate(); + }; + + const handleResign = () => { + if (!canResign) return; + if (!window.confirm('정말 기권하시겠습니까? 이 대국은 패배로 종료됩니다.')) return; + + resignMutation.mutate(); + }; + if (!_hasHydrated) return ; if (!isLoggedIn) return ; if (gameQuery.isLoading) return ; @@ -377,11 +492,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps

-
+
- {moveMutation.isPending && ( -
-
- - Maia 응답 대기 + {isPending && ( +
+
+ + {pendingLabel || '처리 중'}
)} @@ -407,8 +522,12 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
diff --git a/src/components/chess/ChessHistoryClient.tsx b/src/components/chess/ChessHistoryClient.tsx index 40243b2..7e174c2 100644 --- a/src/components/chess/ChessHistoryClient.tsx +++ b/src/components/chess/ChessHistoryClient.tsx @@ -9,9 +9,9 @@ import { getTurnLabel } from '@/components/chess/ChessBoard'; import { formatChessDateTime, getChessErrorMessage, + getChessOutcomeLabel, isAuthError, outcomeBadgeTones, - outcomeLabels, type OutcomeFilter, } from '@/components/chess/chessUi'; import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl'; @@ -95,7 +95,7 @@ function HistoryRow({ game }: { game: ChessGameSummaryResponse }) { >
- {outcomeLabels[game.outcome]} + {getChessOutcomeLabel(game.outcome, game.status)} {game.result && {game.result}}

diff --git a/src/components/chess/ChessHomeClient.tsx b/src/components/chess/ChessHomeClient.tsx index 9892310..6ca7e02 100644 --- a/src/components/chess/ChessHomeClient.tsx +++ b/src/components/chess/ChessHomeClient.tsx @@ -18,7 +18,7 @@ import { import toast from 'react-hot-toast'; import { createChessGame, getChessGames, getChessGameStats } from '@/api/chess'; import { getTurnLabel } from '@/components/chess/ChessBoard'; -import { formatChessDateTime, getChessErrorMessage, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi'; +import { formatChessDateTime, getChessErrorMessage, getChessOutcomeLabel, outcomeBadgeTones, outcomeLabels } from '@/components/chess/chessUi'; import MetricCard from '@/components/ui/MetricCard'; import SegmentedControl, { SegmentedControlOption } from '@/components/ui/SegmentedControl'; import StatusBadge from '@/components/ui/StatusBadge'; @@ -112,6 +112,7 @@ function GameSummaryRow({ playerColor: ChessColor; model: string; outcome: keyof typeof outcomeLabels; + status: string; movesCount: number; updatedAt: string; }; @@ -123,7 +124,7 @@ function GameSummaryRow({ >

- {outcomeLabels[game.outcome]} + {getChessOutcomeLabel(game.outcome, game.status)} Maia {game.model} · {game.rating} diff --git a/src/components/chess/ChessPuzzleClient.tsx b/src/components/chess/ChessPuzzleClient.tsx index 1844d68..1f2b4ee 100644 --- a/src/components/chess/ChessPuzzleClient.tsx +++ b/src/components/chess/ChessPuzzleClient.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Chess as ChessGame, type Color, type Move, type PieceSymbol, type Square } from 'chess.js'; +import { Chess as ChessGame, type Color, type Move, type Square } from 'chess.js'; import { clsx } from 'clsx'; import { AlertCircle, @@ -15,45 +15,15 @@ import { Target, } from 'lucide-react'; import { getTodayChessPuzzle } from '@/api/chess'; +import ChessBoard, { BOARD_SQUARES, type MoveSquares } from '@/components/chess/ChessBoard'; import WindowSurface from '@/components/ui/WindowSurface'; import { type ChessPuzzle } from '@/types'; type FeedbackTone = 'neutral' | 'success' | 'error'; const TIMEZONE = 'Asia/Seoul'; -const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as const; -const RANKS = [8, 7, 6, 5, 4, 3, 2, 1] as const; -const BOARD_SQUARES = RANKS.flatMap((rank) => FILES.map((file) => `${file}${rank}` as Square)); const BOARD_SIZE_STYLE: CSSProperties = { - width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))', -}; - -const PIECE_SYMBOLS: Record> = { - w: { - k: '♔', - q: '♕', - r: '♖', - b: '♗', - n: '♘', - p: '♙', - }, - b: { - k: '♚', - q: '♛', - r: '♜', - b: '♝', - n: '♞', - p: '♟', - }, -}; - -const PIECE_NAMES: Record = { - k: '킹', - q: '퀸', - r: '룩', - b: '비숍', - n: '나이트', - p: '폰', + width: 'min(100%, clamp(18rem, calc(100svh - 23rem), 40rem))', }; const turnLabel = (color: Color) => (color === 'w' ? '백' : '흑'); @@ -67,10 +37,10 @@ const getReadyFeedback = (puzzle: ChessPuzzle): { tone: FeedbackTone; message: s }; }; -const getSquareLabel = (square: Square, piece?: { color: Color; type: PieceSymbol }) => { - if (!piece) return `${square} 빈 칸`; +const getPreferredMove = (moves: Move[], to: Square) => { + const candidates = moves.filter((move) => move.to === to); - return `${square} ${turnLabel(piece.color)} ${PIECE_NAMES[piece.type]}`; + return candidates.find((move) => move.promotion === 'q') ?? candidates[0] ?? null; }; const formatDate = (value: string) => { @@ -176,7 +146,7 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { const [currentFen, setCurrentFen] = useState(puzzle.fen); const [selectedSquare, setSelectedSquare] = useState(null); const [solved, setSolved] = useState(false); - const [lastMoveSquares, setLastMoveSquares] = useState<{ from: Square; to: Square } | null>(null); + const [lastMoveSquares, setLastMoveSquares] = useState(null); const [feedback, setFeedback] = useState(getReadyFeedback(puzzle)); const game = useMemo(() => new ChessGame(currentFen), [currentFen]); @@ -206,8 +176,19 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) { }); }; + const isDraggablePuzzleSquare = (square: Square) => { + if (solved) return false; + + const piece = game.get(square); + + return piece?.color === game.turn(); + }; + const tryMove = (from: Square, to: Square) => { - const candidate = legalMoves.find((move) => move.to === to); + if (solved || !isDraggablePuzzleSquare(from)) return; + + const movesFromSquare = game.moves({ square: from, verbose: true }); + const candidate = getPreferredMove(movesFromSquare, to); if (!candidate) { setFeedback({ @@ -290,77 +271,16 @@ function ChessPuzzleBoard({ puzzle }: { puzzle: ChessPuzzle }) {
-
- {BOARD_SQUARES.map((square, index) => { - const piece = game.get(square); - const file = square[0]; - const rank = square[1]; - const fileIndex = index % 8; - const rankIndex = Math.floor(index / 8); - const isLight = (fileIndex + rankIndex) % 2 === 0; - const isSelected = selectedSquare === square; - const isTarget = legalTargets.has(square); - const isLastMoveSquare = lastMoveSquares?.from === square || lastMoveSquares?.to === square; - - return ( - - ); - })} -
+
diff --git a/src/components/chess/chessUi.ts b/src/components/chess/chessUi.ts index d86fae5..21194e0 100644 --- a/src/components/chess/chessUi.ts +++ b/src/components/chess/chessUi.ts @@ -19,6 +19,12 @@ export const outcomeBadgeTones: Record { + if (status === 'RESIGNED' && outcome === 'LOSS') return '기권패'; + + return outcomeLabels[outcome]; +}; + export const formatChessDateTime = (value?: string) => { if (!value) return '';