feat: add chess resign and undo actions
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s

This commit is contained in:
박원엽
2026-06-24 09:01:13 +09:00
parent 924dd8b372
commit cc4fb0f947
8 changed files with 427 additions and 225 deletions

12
LOG.md
View File

@@ -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`.

View File

@@ -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<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/resign`);
return requireApiData(response.data, '기권을 처리하지 못했습니다.');
};
export const undoChessMove = async (gameId: string) => {
const response = await http.post<ApiResponse<ChessGameResponse>>(`/api/chess/games/${gameId}/undo`);
return requireApiData(response.data, '무르기를 처리하지 못했습니다.');
};

View File

@@ -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<Color, Record<PieceSymbol, string>> = {
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<string>();
const usedSquares = new Set<Square>();
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 (
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
{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];
<div className="relative aspect-square w-full rounded-xl border border-[#3f2d1f]/25 bg-[#4b3828] p-1.5 shadow-[0_20px_42px_rgb(49_35_24_/_0.22),0_1px_0_rgb(255_255_255_/_0.25)_inset] sm:p-2 dark:border-white/10 dark:bg-[#2a241f]">
<div className="relative h-full w-full overflow-hidden rounded-lg border border-black/25 bg-[#1f2a24] shadow-[0_1px_0_rgb(255_255_255_/_0.18)_inset]">
<div className="grid h-full w-full grid-cols-[repeat(8,minmax(0,1fr))] grid-rows-[repeat(8,minmax(0,1fr))]">
{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 (
<button
key={square}
type="button"
onClick={() => onSquareClick?.(square)}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) {
event.preventDefault();
return;
}
return (
<button
key={square}
type="button"
onClick={() => onSquareClick?.(square)}
draggable={canDrag}
onDragStart={(event) => {
if (!canDrag) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', square);
}}
onDragOver={(event) => {
if (!disabled && onSquareDrop) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={(event) => {
if (disabled || !onSquareDrop) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', square);
}}
onDragOver={(event) => {
if (!disabled && onSquareDrop) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={(event) => {
if (disabled || !onSquareDrop) return;
event.preventDefault();
const from = event.dataTransfer.getData('text/plain') as Square;
if (!from || from === square) return;
event.preventDefault();
const from = event.dataTransfer.getData('text/plain') as Square;
if (!from || from === square) return;
onSquareDrop(from, square);
}}
className={clsx(
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.5rem]',
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
!disabled && 'hover:brightness-105 focus-visible:z-20',
disabled && 'cursor-not-allowed',
)}
aria-label={getSquareLabel(square, piece)}
aria-pressed={isSelected}
>
{file === firstFile && (
<span
onSquareDrop(from, square);
}}
className={clsx(
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
)}
>
{rank}
</span>
)}
{rank === lastRank && (
<span
className={clsx(
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
)}
>
{file}
</span>
)}
{isTarget && (
<span
className={clsx(
'absolute h-4 w-4 rounded-full',
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
)}
/>
)}
{piece && (
<span
className={clsx(
'relative z-10 select-none',
'relative min-h-0 min-w-0 overflow-hidden transition-[filter,box-shadow] duration-150',
isLight ? 'bg-[#f0dfc1]' : 'bg-[#6f8f72]',
canDrag && 'cursor-grab active:cursor-grabbing',
piece.color === 'w'
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
!disabled && 'hover:brightness-110 focus-visible:z-20',
disabled && 'cursor-not-allowed',
)}
aria-label={getSquareLabel(square, piece)}
aria-pressed={isSelected}
>
{PIECE_SYMBOLS[piece.color][piece.type]}
{isLastMoveSquare && (
<span className="absolute inset-1 rounded-md bg-amber-300/35 shadow-[0_0_0_1px_rgb(255_244_184_/_0.42)_inset,0_0_18px_rgb(245_158_11_/_0.28)]" />
)}
{isSelected && (
<span className="absolute inset-1 rounded-md border-2 border-sky-300 bg-sky-300/20 shadow-[0_0_18px_rgb(56_189_248_/_0.42)]" />
)}
{file === firstFile && (
<span
className={clsx(
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
)}
>
{rank}
</span>
)}
{rank === lastRank && (
<span
className={clsx(
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
)}
>
{file}
</span>
)}
{isTarget && (
<span
className={clsx(
'absolute z-10 rounded-full transition',
piece
? 'inset-[12%] border-[3px] border-rose-500/55 shadow-[0_0_0_1px_rgb(255_255_255_/_0.26)_inset]'
: 'left-1/2 top-1/2 h-[24%] w-[24%] -translate-x-1/2 -translate-y-1/2 bg-slate-950/35 shadow-[0_1px_3px_rgb(0_0_0_/_0.24)]',
)}
/>
)}
</button>
);
})}
</div>
<div className="pointer-events-none absolute inset-0">
{visualPieces.map((piece) => {
const canDrag = !disabled && (isDraggableSquare?.(piece.square) ?? true);
const isSelected = selectedSquare === piece.square;
const isLastMovePiece = lastMoveSquares?.to === piece.square;
return (
<span
key={piece.id}
className="absolute left-0 top-0 flex h-[12.5%] w-[12.5%] items-center justify-center transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={getSquarePosition(piece.square, orientation)}
aria-hidden="true"
>
<span
className={clsx(
'flex h-[82%] w-[82%] select-none items-center justify-center rounded-full font-serif text-[2rem] font-bold leading-none transition-[transform,filter] duration-200 sm:text-[2.45rem] md:text-[3.35rem]',
canDrag && 'cursor-grab active:cursor-grabbing',
isSelected && 'scale-110 -translate-y-0.5 brightness-110',
isLastMovePiece && 'scale-105',
piece.color === 'w'
? 'border border-[#6a4f31]/45 bg-[radial-gradient(circle_at_35%_28%,#fffdf7_0%,#f5ead7_62%,#dbc8a5_100%)] text-[#2f271f] shadow-[0_5px_10px_rgb(53_39_25_/_0.30),0_1px_0_rgb(255_255_255_/_0.75)_inset] [text-shadow:0_1px_0_rgb(255_255_255_/_0.65)]'
: 'border border-black/45 bg-[radial-gradient(circle_at_35%_28%,#53565d_0%,#1f2329_64%,#090b0f_100%)] text-[#fff1d1] shadow-[0_7px_12px_rgb(0_0_0_/_0.42),0_1px_0_rgb(255_255_255_/_0.22)_inset] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.8)]',
)}
>
{PIECE_SYMBOLS[piece.color][piece.type]}
</span>
</span>
)}
</button>
);
})}
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<main className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 px-0 py-3 md:py-6">
@@ -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 (
<WindowSurface title="Game Info" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
<div className="mb-4 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
</div>
@@ -153,11 +172,43 @@ function GameInfoPanel({
)}
>
{gameEnded && '종료된 대국입니다.'}
{!gameEnded && pending && 'Maia 응답을 기다리는 중입니다.'}
{!gameEnded && pending && '요청을 처리하는 중입니다.'}
{!gameEnded && !pending && isPlayerTurn && '내 차례입니다. 말을 움직이세요.'}
{!gameEnded && !pending && !isPlayerTurn && 'Maia 차례입니다.'}
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3 xl:grid-cols-1">
{game.status === 'IN_PROGRESS' && (
<>
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-[var(--control-border)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--card-bg-strong)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Undo2 size={16} />
</button>
<button
type="button"
onClick={onResign}
disabled={!canResign}
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg border border-red-500/20 bg-red-500/10 px-3 text-sm font-semibold text-red-700 shadow-[var(--shadow-control)] transition hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-300"
>
<Flag size={16} />
</button>
</>
)}
<Link
href="/chess"
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] px-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<PlusCircle size={16} />
</Link>
</div>
<div className="mt-4 rounded-lg border border-[var(--color-line)] bg-black/[0.025] px-3 py-3 dark:bg-white/[0.06]">
<p className="text-xs font-semibold text-[var(--color-text-subtle)]"> Maia </p>
<p className="mt-1 break-words font-mono text-sm font-semibold text-[var(--color-text)]">
@@ -190,6 +241,7 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
const { isLoggedIn, _hasHydrated } = useAuthStore();
const [selectedSquare, setSelectedSquare] = useState<Square | null>(null);
const [optimisticFen, setOptimisticFen] = useState<string | null>(null);
const [optimisticMoveSquares, setOptimisticMoveSquares] = useState<MoveSquares | null>(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<Move[]>(() => {
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 <LoadingState message="로그인 상태를 확인하는 중입니다." />;
if (!isLoggedIn) return <LoginRequired />;
if (gameQuery.isLoading) return <LoadingState />;
@@ -377,11 +492,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
<section className="grid min-w-0 items-start gap-5 xl:grid-cols-[minmax(0,1fr)_22rem]">
<WindowSurface
title="Board"
subtitle={moveMutation.isPending ? 'Maia thinking' : isPlayerTurn ? 'Your move' : 'Synced'}
subtitle={isPending ? 'Updating' : isPlayerTurn ? 'Your move' : 'Synced'}
showTrafficLights={false}
bodyClassName="p-3 md:p-5"
>
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(18rem, calc(100svh - 15rem), 42rem))' }}>
<div className="relative mx-auto max-w-full" style={{ width: 'min(100%, clamp(18rem, calc(100svh - 23rem), 40rem))' }}>
<ChessBoard
fen={currentFen}
orientation={game.playerColor}
@@ -393,11 +508,11 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
onSquareClick={handleSquareClick}
onSquareDrop={attemptMove}
/>
{moveMutation.isPending && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/20 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-black/55 px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-control)]">
<Loader2 className="animate-spin" size={17} />
Maia
{isPending && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-30 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full border border-white/25 bg-black/62 px-4 py-2 text-sm font-semibold text-white shadow-[0_12px_28px_rgb(0_0_0_/_0.28)] backdrop-blur-md">
<Loader2 className="animate-spin" size={16} />
{pendingLabel || '처리 중'}
</div>
</div>
)}
@@ -407,8 +522,12 @@ export default function ChessGamePlayClient({ gameId }: ChessGamePlayClientProps
<GameInfoPanel
game={game}
isPlayerTurn={isPlayerTurn}
pending={moveMutation.isPending}
pending={isPending}
canUndo={canUndo}
canResign={canResign}
onCopyPgn={copyPgn}
onUndo={handleUndo}
onResign={handleResign}
/>
</section>
</ChessPageFrame>

View File

@@ -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 }) {
>
<div className="min-w-0">
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
{game.result && <StatusBadge tone="neutral">{game.result}</StatusBadge>}
</div>
<p className="truncate text-sm font-bold text-[var(--color-text)]">

View File

@@ -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({
>
<div className="min-w-0">
<div className="mb-1.5 flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{outcomeLabels[game.outcome]}</StatusBadge>
<StatusBadge tone={outcomeBadgeTones[game.outcome]}>{getChessOutcomeLabel(game.outcome, game.status)}</StatusBadge>
<span className="text-xs font-semibold text-[var(--color-text-subtle)]">
Maia {game.model} · {game.rating}
</span>

View File

@@ -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<Color, Record<PieceSymbol, string>> = {
w: {
k: '♔',
q: '♕',
r: '♖',
b: '♗',
n: '♘',
p: '♙',
},
b: {
k: '♚',
q: '♛',
r: '♜',
b: '♝',
n: '♞',
p: '♟',
},
};
const PIECE_NAMES: Record<PieceSymbol, string> = {
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<Square | null>(null);
const [solved, setSolved] = useState(false);
const [lastMoveSquares, setLastMoveSquares] = useState<{ from: Square; to: Square } | null>(null);
const [lastMoveSquares, setLastMoveSquares] = useState<MoveSquares | null>(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 }) {
<section className="grid min-w-0 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
<BoardWindow>
<div className="grid aspect-square w-full grid-cols-8 overflow-hidden rounded-lg border border-[var(--color-line)] shadow-[var(--shadow-control)]">
{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 (
<button
key={square}
type="button"
onClick={() => handleSquareClick(square)}
className={clsx(
'relative flex aspect-square items-center justify-center overflow-hidden text-[2rem] leading-none transition sm:text-[2.5rem] md:text-[3.75rem]',
isLight ? 'bg-[#eef0e6]' : 'bg-[#5f8d68]',
isSelected && 'z-10 ring-4 ring-[var(--color-accent)] ring-inset',
isLastMoveSquare && 'bg-[var(--color-accent-soft)]',
!solved && 'hover:brightness-105 focus-visible:z-20',
)}
aria-label={getSquareLabel(square, piece)}
aria-pressed={isSelected}
>
{file === 'a' && (
<span
className={clsx(
'absolute left-1.5 top-1 text-[10px] font-bold leading-none',
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
)}
>
{rank}
</span>
)}
{rank === '1' && (
<span
className={clsx(
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
)}
>
{file}
</span>
)}
{isTarget && (
<span
className={clsx(
'absolute h-4 w-4 rounded-full',
piece ? 'h-full w-full rounded-none ring-4 ring-black/20 ring-inset' : 'bg-black/20',
)}
/>
)}
{piece && (
<span
className={clsx(
'relative z-10 select-none',
piece.color === 'w'
? 'text-[#fbfbfb] [text-shadow:0_1px_2px_rgb(0_0_0_/_0.55)]'
: 'text-[#202124] [text-shadow:0_1px_1px_rgb(255_255_255_/_0.22)]',
)}
>
{PIECE_SYMBOLS[piece.color][piece.type]}
</span>
)}
</button>
);
})}
</div>
<ChessBoard
fen={currentFen}
selectedSquare={selectedSquare}
legalTargets={legalTargets}
lastMoveSquares={lastMoveSquares}
disabled={solved}
isDraggableSquare={isDraggablePuzzleSquare}
onSquareClick={handleSquareClick}
onSquareDrop={tryMove}
/>
</BoardWindow>
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">

View File

@@ -19,6 +19,12 @@ export const outcomeBadgeTones: Record<ChessOutcome, 'neutral' | 'info' | 'succe
UNKNOWN: 'neutral',
};
export const getChessOutcomeLabel = (outcome: ChessOutcome, status?: string) => {
if (status === 'RESIGNED' && outcome === 'LOSS') return '기권패';
return outcomeLabels[outcome];
};
export const formatChessDateTime = (value?: string) => {
if (!value) return '';