feat: add chess resign and undo actions
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m16s
This commit is contained in:
12
LOG.md
12
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`.
|
||||
|
||||
@@ -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, '무르기를 처리하지 못했습니다.');
|
||||
};
|
||||
|
||||
@@ -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,9 +202,24 @@ 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)]">
|
||||
<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);
|
||||
@@ -156,21 +261,26 @@ export default function ChessBoard({
|
||||
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',
|
||||
'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',
|
||||
!disabled && 'hover:brightness-110 focus-visible:z-20',
|
||||
disabled && 'cursor-not-allowed',
|
||||
)}
|
||||
aria-label={getSquareLabel(square, piece)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{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-[#5f8d68]' : 'text-[#eef0e6]',
|
||||
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
|
||||
)}
|
||||
>
|
||||
{rank}
|
||||
@@ -180,7 +290,7 @@ export default function ChessBoard({
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute bottom-1 right-1.5 text-[10px] font-bold leading-none',
|
||||
isLight ? 'text-[#5f8d68]' : 'text-[#eef0e6]',
|
||||
isLight ? 'text-[#6f8f72]' : 'text-[#f0dfc1]',
|
||||
)}
|
||||
>
|
||||
{file}
|
||||
@@ -189,27 +299,49 @@ export default function ChessBoard({
|
||||
{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',
|
||||
'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)]',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{piece && (
|
||||
<span
|
||||
className={clsx(
|
||||
'relative z-10 select-none',
|
||||
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)]',
|
||||
)}
|
||||
>
|
||||
{PIECE_SYMBOLS[piece.color][piece.type]}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 moveMutation = useMutation({
|
||||
mutationFn: (move: string) => postChessMove(gameId, { move }),
|
||||
onSuccess: async (updatedGame) => {
|
||||
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) => {
|
||||
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>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
<ChessBoard
|
||||
fen={currentFen}
|
||||
selectedSquare={selectedSquare}
|
||||
legalTargets={legalTargets}
|
||||
lastMoveSquares={lastMoveSquares}
|
||||
disabled={solved}
|
||||
isDraggableSquare={isDraggablePuzzleSquare}
|
||||
onSquareClick={handleSquareClick}
|
||||
onSquareDrop={tryMove}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</BoardWindow>
|
||||
|
||||
<WindowSurface title="Puzzle" showTrafficLights={false} as="aside" bodyClassName="p-4 md:p-5">
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user