-
-
-
-
-
-
-
-
-
- {ranks.map((rank) =>
- files.map((file) => {
- const square = `${file}${rank}`;
- const piece = pieceMap[square];
- const isLight = (FILES.indexOf(file) + rank) % 2 === 0;
-
- return (
- handleSquareClick(square)}
- />
- );
- })
- )}
-
-
-
-
-
-
-
- {promotionPending && (
-
- )}
+
+ onMove(sourceSquare, targetSquare);
+ }
+
+ // Reset the board selection after the drop
+ setSelected(null);
+ setLegalSquares(new Set());
+ },
+ [legalSquares, pieceMap, onMove],
+ );
+
+ // --- CLICK HANDLER (Kept as a fallback for accessibility!) ---
+ const handleSquareClick = useCallback(
+ (square: Square) => {
+ if (disabled || promotionPending) return;
+
+ if (selected && legalSquares.has(square)) {
+ const piece = pieceMap[selected];
+ const isPromotion =
+ (piece === "P" && square[1] === "8") ||
+ (piece === "p" && square[1] === "1");
+
+ if (isPromotion) {
+ setPromotionPending({ from: selected, to: square });
+ setSelected(null);
+ setLegalSquares(new Set());
+ return;
+ }
+
+ onMove(selected, square);
+ setSelected(null);
+ setLegalSquares(new Set());
+ return;
+ }
+
+ if (selected === square) {
+ setSelected(null);
+ setLegalSquares(new Set());
+ return;
+ }
+
+ if (pieceMap[square]) {
+ setSelected(square);
+ setLegalSquares(getLegalSquares(square, position));
+ return;
+ }
+
+ setSelected(null);
+ setLegalSquares(new Set());
+ },
+ [
+ disabled,
+ selected,
+ legalSquares,
+ pieceMap,
+ promotionPending,
+ position,
+ onMove,
+ getLegalSquares,
+ ],
+ );
+
+ const handlePromotion = useCallback(
+ (piece: string) => {
+ if (!promotionPending) return;
+ onMove(promotionPending.from, promotionPending.to, piece);
+ setPromotionPending(null);
+ },
+ [promotionPending, onMove],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ {ranks.map((rank) =>
+ files.map((file) => {
+ const square = `${file}${rank}`;
+ const piece = pieceMap[square];
+ const isLight = (FILES.indexOf(file) + rank) % 2 === 0;
+
+ return (
+ handleSquareClick(square)}
+ // Wire up our new Drag functions!
+ onDragStart={(e) => handleDragStart(e, square)}
+ onDragOver={handleDragOver}
+ onDrop={(e) => handleDrop(e, square)}
+ />
+ );
+ }),
+ )}
- );
-}
\ No newline at end of file
+
+
+
+
+
+
+ {promotionPending && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/chess/GameAlerts.tsx b/frontend/src/components/chess/GameAlerts.tsx
index c98141a..287e82b 100644
--- a/frontend/src/components/chess/GameAlerts.tsx
+++ b/frontend/src/components/chess/GameAlerts.tsx
@@ -1,101 +1,84 @@
"use client";
-import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { GameStatus, Color } from "@/store/chess/useChessStore";
// Types
interface Props {
- status: GameStatus;
- activeColor: Color;
+ status: GameStatus;
+ activeColor: Color;
}
interface AlertConfig {
- variant: "default" | "destructive";
- title: string;
- description: string;
- transient: boolean; // auto-dismiss after 3 s if true
+ variant: "default" | "destructive";
+ title: string;
+ description: string;
}
// Alert Map
-function resolveAlert(status: GameStatus, activeColor: Color): AlertConfig | null {
- const side = activeColor === "w" ? "White" : "Black";
+function resolveAlert(status: GameStatus, activeColor: Color): AlertConfig {
+ const side = activeColor === "w" ? "White" : "Black";
- switch (status) {
- case "check":
- return {
- variant: "destructive",
- title: "Check",
- description: `${side} is in check.`,
- transient: true,
- };
- case "checkmate":
- return {
- variant: "destructive",
- title: "Checkmate",
- description: `${side} has been checkmated. Game over.`,
- transient: false,
- };
- case "stalemate":
- return {
- variant: "default",
- title: "Stalemate",
- description: "No legal moves — the game is a draw.",
- transient: false,
- };
- case "draw":
- return {
- variant: "default",
- title: "Draw",
- description: "The game has ended in a draw.",
- transient: false,
- };
- case "resigned":
- return {
- variant: "default",
- title: "Resigned",
- description: `${side} has resigned. Game over.`,
- transient: false,
- };
- default:
- return null;
- }
+ switch (status) {
+ case "check":
+ return {
+ variant: "destructive",
+ title: "⚠️ Check",
+ description: `${side} is in check!`,
+ };
+ case "checkmate":
+ return {
+ variant: "destructive",
+ title: "Checkmate",
+ description: `${side} has been checkmated. Game over.`,
+ };
+ case "stalemate":
+ return {
+ variant: "default",
+ title: "Stalemate",
+ description: "No legal moves — the game is a draw.",
+ };
+ case "draw":
+ return {
+ variant: "default",
+ title: "Draw",
+ description: "The game has ended in a draw.",
+ };
+ case "resigned":
+ return {
+ variant: "default",
+ title: "Resigned",
+ description: `${side} has resigned. Game over.`,
+ };
+ case "playing":
+ default:
+ return {
+ variant: "default",
+ title: "Game in Progress",
+ description: `${side} to move.`,
+ };
+ }
}
// Component
export default function GameAlerts({ status, activeColor }: Props) {
- const [dismissed, setDismissed] = useState(false);
- const [prevStatus, setPrevStatus] = useState(status);
+ const config = resolveAlert(status, activeColor);
- // Reset on every status change
- useEffect(() => {
- if (status !== prevStatus) {
- setDismissed(false);
- setPrevStatus(status);
- }
- }, [status, prevStatus]);
-
- const config = resolveAlert(status, activeColor);
-
- // Auto-dismiss transient alerts (check)
- useEffect(() => {
- if (!config?.transient) return;
- const t = setTimeout(() => setDismissed(true), 3000);
- return () => clearTimeout(t);
- }, [config]);
-
- if (!config || dismissed) return null;
-
- return (
-
- {config.title}
- {config.description}
-
- );
-}
\ No newline at end of file
+ return (
+
+
+ {config.title}
+
+
+ {config.description}
+
+
+ );
+}
diff --git a/frontend/src/components/chess/GameClock.tsx b/frontend/src/components/chess/GameClock.tsx
index 33cb9f4..3ed77a3 100644
--- a/frontend/src/components/chess/GameClock.tsx
+++ b/frontend/src/components/chess/GameClock.tsx
@@ -6,86 +6,91 @@ import { cn } from "@/lib/utils";
// Types
interface Props {
- timeLeft: number; // seconds remaining
- isActive: boolean;
- onTick?: () => void; // parent owns state; called each second
- onExpire?: () => void;
+ timeLeft: number; // seconds remaining
+ isActive: boolean;
+ onTick?: () => void; // parent owns state; called each second
+ onExpire?: () => void;
}
// Helpers
function formatTime(seconds: number): string {
- const s = Math.max(0, Math.floor(seconds));
- const m = Math.floor(s / 60);
- const ss = s % 60;
- return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
+ const s = Math.max(0, Math.floor(seconds));
+ const m = Math.floor(s / 60);
+ const ss = s % 60;
+ return `${String(m).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
}
// Component
-export default function GameClock({ timeLeft, isActive, onTick, onExpire }: Props) {
- const intervalRef = useRef
| null>(null);
+export default function GameClock({
+ timeLeft,
+ isActive,
+ onTick,
+ onExpire,
+}: Props) {
+ const intervalRef = useRef | null>(null);
- useEffect(() => {
- if (!isActive) {
- if (intervalRef.current) clearInterval(intervalRef.current);
- return;
- }
+ useEffect(() => {
+ if (!isActive) {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ return;
+ }
- intervalRef.current = setInterval(() => {
- if (timeLeft <= 1) {
- clearInterval(intervalRef.current!);
- onTick?.();
- onExpire?.();
- } else {
- onTick?.();
- }
- }, 1000);
+ intervalRef.current = setInterval(() => {
+ if (timeLeft <= 1) {
+ clearInterval(intervalRef.current!);
+ onTick?.();
+ onExpire?.();
+ } else {
+ onTick?.();
+ }
+ }, 1000);
- return () => {
- if (intervalRef.current) clearInterval(intervalRef.current);
- };
- }, [isActive, timeLeft, onTick, onExpire]);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [isActive, timeLeft, onTick, onExpire]);
- const isCritical = timeLeft <= 10;
- const isLow = timeLeft <= 30 && !isCritical;
+ const isCritical = timeLeft <= 10;
+ const isLow = timeLeft <= 30 && !isCritical;
- return (
-
- {/* Active indicator */}
-
+ return (
+
+ {/* Active indicator */}
+
- {/* Time display */}
-
- {formatTime(timeLeft)}
-
+ {/* Time display */}
+
+ {formatTime(timeLeft)}
+
- {/* Center Time */}
-
-
- );
-}
\ No newline at end of file
+ {/* Center Time */}
+
+
+ );
+}
diff --git a/frontend/src/components/chess/MoveHistory.tsx b/frontend/src/components/chess/MoveHistory.tsx
index 364e557..4888a08 100644
--- a/frontend/src/components/chess/MoveHistory.tsx
+++ b/frontend/src/components/chess/MoveHistory.tsx
@@ -8,100 +8,100 @@ import type { MoveEntry } from "@/store/chess/useChessStore";
// Types
interface Props {
- moves: MoveEntry[];
+ moves: MoveEntry[];
}
interface MovePair {
- moveNumber: number;
- white: string | null;
- black: string | null;
+ moveNumber: number;
+ white: string | null;
+ black: string | null;
}
// Helpers
function pairMoves(moves: MoveEntry[]): MovePair[] {
- const pairs: MovePair[] = [];
- for (let i = 0; i < moves.length; i += 2) {
- pairs.push({
- moveNumber: moves[i].moveNumber,
- white: moves[i]?.san ?? null,
- black: moves[i + 1]?.san ?? null,
- });
- }
- return pairs;
+ const pairs: MovePair[] = [];
+ for (let i = 0; i < moves.length; i += 2) {
+ pairs.push({
+ moveNumber: moves[i].moveNumber,
+ white: moves[i]?.san ?? null,
+ black: moves[i + 1]?.san ?? null,
+ });
+ }
+ return pairs;
}
// Component
export default function MoveHistory({ moves }: Props) {
- const bottomRef = useRef(null);
+ const bottomRef = useRef(null);
- useEffect(() => {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [moves]);
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [moves]);
- const pairs = pairMoves(moves);
+ const pairs = pairMoves(moves);
- return (
-
-
-
-
- Moves
-
-
- {moves.length > 0 ? `${moves.length} ply` : "—"}
-
-
-
-
-
- {pairs.length === 0 ? (
-
- No moves yet.
-
- ) : (
-
-
-
- |
- #
- |
-
- White
- |
-
- Black
- |
-
-
-
- {pairs.map((pair, idx) => (
-
- |
- {pair.moveNumber}.
- |
-
- {pair.white ?? ""}
- |
-
- {pair.black ?? ""}
- |
-
- ))}
-
-
- )}
-
-
-
-
- );
-}
\ No newline at end of file
+ return (
+
+
+
+
+ Moves
+
+
+ {moves.length > 0 ? `${moves.length} ply` : "—"}
+
+
+
+
+
+ {pairs.length === 0 ? (
+
+ No moves yet.
+
+ ) : (
+
+
+
+ |
+ #
+ |
+
+ White
+ |
+
+ Black
+ |
+
+
+
+ {pairs.map((pair, idx) => (
+
+ |
+ {pair.moveNumber}.
+ |
+
+ {pair.white ?? ""}
+ |
+
+ {pair.black ?? ""}
+ |
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/chess/MoveValidationToast.tsx b/frontend/src/components/chess/MoveValidationToast.tsx
index 09974c1..bf818fa 100644
--- a/frontend/src/components/chess/MoveValidationToast.tsx
+++ b/frontend/src/components/chess/MoveValidationToast.tsx
@@ -5,29 +5,28 @@ import { toast } from "sonner";
import type { ValidationResult } from "@/store/chess/useChessStore";
interface Props {
- result: ValidationResult | null;
+ result: ValidationResult | null;
}
export default function MoveValidationToast({ result }: Props) {
- const lastResultRef = useRef(null);
+ const lastResultRef = useRef(null);
- useEffect(() => {
- if (!result) return;
- // Deduplicate — only fire when result reference changes
- if (result === lastResultRef.current) return;
- lastResultRef.current = result;
+ useEffect(() => {
+ if (!result) return;
+ // Deduplicate — only fire when result reference changes
+ if (result === lastResultRef.current) return;
+ lastResultRef.current = result;
- if (!result.valid) {
- toast.error(result.reason ?? "Illegal move", {
- duration: 2500,
- position: "bottom-center",
- classNames: {
- toast: "font-roboto text-xs",
- },
- });
- }
- }, [result]);
+ if (!result.valid) {
+ toast.error(result.reason ?? "Illegal move", {
+ duration: 2500,
+ position: "bottom-center",
+ classNames: {
+ toast: "font-roboto text-xs",
+ },
+ });
+ }
+ }, [result]);
-
- return null;
-}
\ No newline at end of file
+ return null;
+}
diff --git a/frontend/src/components/chess/board/BoardLabels.tsx b/frontend/src/components/chess/board/BoardLabels.tsx
index 0464154..12f9f9b 100644
--- a/frontend/src/components/chess/board/BoardLabels.tsx
+++ b/frontend/src/components/chess/board/BoardLabels.tsx
@@ -1,38 +1,46 @@
"use client";
interface Props {
- files: readonly string[];
- ranks: readonly number[];
+ files: readonly string[];
+ ranks: readonly number[];
}
export function FileLabelRow({ files }: Pick) {
- return (
-
- {files.map((f) => (
-
- {f}
-
- ))}
-
- );
+ return (
+
+ {files.map((f) => (
+
+ {f}
+
+ ))}
+
+ );
}
-export function RankLabelCol({ ranks, side }: { ranks: readonly number[]; side: "left" | "right" }) {
- return (
-
- {ranks.map((r) => (
-
- {r}
-
- ))}
-
- );
-}
\ No newline at end of file
+export function RankLabelCol({
+ ranks,
+ side,
+}: {
+ ranks: readonly number[];
+ side: "left" | "right";
+}) {
+ return (
+
+ {ranks.map((r) => (
+
+ {r}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/chess/board/BoardSquare.tsx b/frontend/src/components/chess/board/BoardSquare.tsx
index f3059e3..e28eacc 100644
--- a/frontend/src/components/chess/board/BoardSquare.tsx
+++ b/frontend/src/components/chess/board/BoardSquare.tsx
@@ -1,80 +1,93 @@
-// Single square on the board which renders the piece glyph
-// Highlight legal moves - to be improved
-// capture and selection highlight
-
-
-
"use client";
import { cn } from "@/lib/utils";
-
-// Glyphs are temporary placeholders
-const T = "\uFE0E";
-export const PIECE_GLYPHS: Record = {
- K: `♔${T}`, Q: `♕${T}`, R: `♖${T}`, B: `♗${T}`, N: `♘${T}`, P: `♙${T}`,
- k: `♚${T}`, q: `♛${T}`, r: `♜${T}`, b: `♝${T}`, n: `♞${T}`, p: `♟${T}`,
-};
+import Piece from "./Piece";
+import { motion } from "framer-motion";
interface Props {
- square: string;
- piece: string | undefined;
- isLight: boolean;
- isSelected: boolean;
- isLegal: boolean;
- isCapture: boolean;
- disabled: boolean;
- onClick: () => void;
+ square: string;
+ piece: string | undefined;
+ isLight: boolean;
+ isSelected: boolean;
+ isLegal: boolean;
+ isCapture: boolean;
+ disabled: boolean;
+ onClick: () => void;
+ // New Drag Events
+ onDragStart: (e: React.DragEvent) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent) => void;
}
export default function BoardSquare({
- square,
- piece,
- isLight,
- isSelected,
- isLegal,
- isCapture,
- disabled,
- onClick,
+ square,
+ piece,
+ isLight,
+ isSelected,
+ isLegal,
+ isCapture,
+ disabled,
+ onClick,
+ onDragStart,
+ onDragOver,
+ onDrop,
}: Props) {
- const isWhitePiece = piece === piece?.toUpperCase();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/chess/board/Piece.tsx b/frontend/src/components/chess/board/Piece.tsx
new file mode 100644
index 0000000..21e2a6c
--- /dev/null
+++ b/frontend/src/components/chess/board/Piece.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import {
+ FaChessKing,
+ FaChessQueen,
+ FaChessRook,
+ FaChessBishop,
+ FaChessKnight,
+ FaChessPawn,
+} from "react-icons/fa6";
+
+interface PieceProps {
+ type: string;
+ className?: string;
+}
+
+export default function Piece({ type, className }: PieceProps) {
+ const isWhite = type === type.toUpperCase();
+ const normalizedType = type.toLowerCase();
+
+ const Icon = {
+ k: FaChessKing,
+ q: FaChessQueen,
+ r: FaChessRook,
+ b: FaChessBishop,
+ n: FaChessKnight,
+ p: FaChessPawn,
+ }[normalizedType];
+
+ if (!Icon) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/chess/board/PromotionPicker.tsx b/frontend/src/components/chess/board/PromotionPicker.tsx
index fd3234e..0642469 100644
--- a/frontend/src/components/chess/board/PromotionPicker.tsx
+++ b/frontend/src/components/chess/board/PromotionPicker.tsx
@@ -1,35 +1,41 @@
-// Pick a promotion piece
-
"use client";
-import { PIECE_GLYPHS } from "./BoardSquare";
+import Piece from "./Piece";
interface Props {
- onSelect: (piece: string) => void;
+ color?: "w" | "b";
+ onSelect: (piece: string) => void;
}
-export default function PromotionPicker({ onSelect }: Props) {
- return (
-
-
- Promote to
-
-
- {["q", "r", "b", "n"].map((p) => (
- onSelect(p)}
- aria-label={`Promote to ${p}`}
- className="w-14 h-14 flex items-center justify-center rounded-md border border-border bg-card text-[2rem] hover:bg-accent hover:border-primary/50 transition-colors cursor-pointer"
- >
- {PIECE_GLYPHS[p]}
-
- ))}
-
-
- );
-}
\ No newline at end of file
+export default function PromotionPicker({ color = "w", onSelect }: Props) {
+ return (
+
+
+ Promote to
+
+
+ {["q", "r", "b", "n"].map((p) => {
+ const pieceType = color === "w" ? p.toUpperCase() : p;
+
+ return (
+
onSelect(p)}
+ aria-label={`Promote to ${p}`}
+ className="w-14 h-14 flex items-center justify-center rounded-md border border-border bg-card hover:bg-accent hover:border-primary/50 shadow-xl transition-all hover:scale-105 cursor-pointer"
+ >
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/components/chess/board/TurnIndicator.tsx b/frontend/src/components/chess/board/TurnIndicator.tsx
index b19a4ee..12f3cfd 100644
--- a/frontend/src/components/chess/board/TurnIndicator.tsx
+++ b/frontend/src/components/chess/board/TurnIndicator.tsx
@@ -6,27 +6,27 @@ import { cn } from "@/lib/utils";
import type { Color, GameStatus } from "@/store/chess/useChessStore";
interface Props {
- activeColor: Color;
- status: GameStatus;
+ activeColor: Color;
+ status: GameStatus;
}
export default function TurnIndicator({ activeColor, status }: Props) {
- const isGameActive = status === "playing" || status === "check";
+ const isGameActive = status === "playing" || status === "check";
- if (!isGameActive) return ;
+ if (!isGameActive) return ;
- return (
-
-
-
- {activeColor === "w" ? "White" : "Black"} to move
-
-
- );
-}
\ No newline at end of file
+ return (
+
+
+
+ {activeColor === "w" ? "White" : "Black"} to move
+
+
+ );
+}
diff --git a/frontend/src/components/chess/screens/GameScreen.tsx b/frontend/src/components/chess/screens/GameScreen.tsx
index 948914d..fd75468 100644
--- a/frontend/src/components/chess/screens/GameScreen.tsx
+++ b/frontend/src/components/chess/screens/GameScreen.tsx
@@ -9,124 +9,215 @@ import GameAlerts from "@/components/chess/GameAlerts";
import DrawOfferModal from "@/components/chess/DrawOfferModal";
import { Button } from "@/components/ui/button";
+// FOR Captured Pieces
+import { getMaterialAdvantage } from "@/lib/chess/chess-utils";
+import CapturedPieces from "@/components/chess/CapturedPieces";
+
+// UI Components
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+
export default function GameScreen() {
- const {
- position,
- activeColor,
- status,
- moveHistory,
- lastValidation,
- playerColor,
- clocks,
- makeMove,
- tickClock,
- endGame,
- reset,
- } = useChessStore();
+ const {
+ position,
+ activeColor,
+ status,
+ moveHistory,
+ lastValidation,
+ playerColor,
+ clocks,
+ makeMove,
+ tickClock,
+ endGame,
+ reset,
+ } = useChessStore();
- const [showDrawModal, setShowDrawModal] = useState(false);
- const orientation = playerColor ?? "w";
- const opponentColor = orientation === "w" ? "b" : "w";
+ // Add this line right here!
+ const material = getMaterialAdvantage(position);
- const isGameOver = status === "checkmate" || status === "stalemate" || status === "draw" || status === "resigned";
- const isBoardDisabled = isGameOver;
+ const [showDrawModal, setShowDrawModal] = useState(false);
+ const orientation = playerColor ?? "w";
+ const opponentColor = orientation === "w" ? "b" : "w";
- // TODO: remove const isBoardDisabled = isGameOver; and replace the commented code below for player pieces constraints
+ const isGameOver =
+ status === "checkmate" ||
+ status === "stalemate" ||
+ status === "draw" ||
+ status === "resigned";
+ const isBoardDisabled = isGameOver;
- //const isBoardDisabled = playerColor !== null
- //? activeColor !== orientation || status !== "playing"
- //: status !== "playing";
+ // TODO: uncomment when player constraints are needed
+ // const isBoardDisabled = playerColor !== null
+ // ? activeColor !== orientation || status !== "playing"
+ // : status !== "playing";
- return (
-
+ return (
+ // Main Container: Center everything, use a column on mobile, row on large screens
+
+ {/* LEFT COLUMN: Players & Board */}
+
+ {/* OPPONENT TOP BAR */}
+
+
+
+
+
+ OP
+
+
+
+
+
+ Opponent
+
+
+ 1200 ELO
+
+
+ {/* Captured pieces placeholder */}
- {/* Opponent panel */}
-
-
- {/* TODO: [F503 - Show opponent's time] Replace local tickClock with socket.on("chess:clockSync") */}
-
tickClock(opponentColor)}
- onExpire={() => endGame("checkmate")}
+
+ {/* We will inject captured pieces here next */}
+
+
+
+ {/* Clock inside the bar */}
+
+ 0
+ }
+ onTick={() => tickClock(opponentColor)}
+ onExpire={() => endGame("checkmate")}
+ />
+
+
- {/* Board + alerts */}
-
-
-
- {/* TODO: [F503 - Implement resign/draw] Wire Resign to emit("chess:resign") and Offer Draw to emit("chess:offerDraw") via socket */}
-
- setShowDrawModal(true)}
- disabled={isGameOver}
- >
- Offer Draw
-
- endGame("resigned")}
- disabled={isGameOver}
- >
- Resign
-
- {isGameOver && (
-
- Back to Lobby
-
- )}
-
-
+ {/* THE BOARD */}
+
+
+
- {/* Player panel */}
-
-
-
tickClock(orientation)}
- onExpire={() => endGame("resigned")}
+ {/* PLAYER BOTTOM BAR */}
+
+
+
+
+
+ ME
+
+
+
+
+
+ You
+
+
+ 1200 ELO
+
+
+ {/* Captured pieces placeholder */}
+
+ {/* We will inject captured pieces here next */}
+
-
+
+
+ {/* Clock inside the bar */}
+
+ 0
+ }
+ onTick={() => tickClock(orientation)}
+ onExpire={() => endGame("resigned")}
+ />
+
+
+
- {showDrawModal && (
-
{ endGame("draw"); setShowDrawModal(false); }}
- onDecline={() => setShowDrawModal(false)}
- />
- )}
+ {/* RIGHT COLUMN: Sidebar (Move History & Actions) */}
+
+ {/* 👇 ADD GAME ALERTS HERE 👇 */}
+
+
+
+ {/* Fixed height container for move history so it doesn't stretch weirdly */}
+
+
- );
-}
\ No newline at end of file
+
+ {/* Game Controls */}
+
+ {!isGameOver ? (
+ <>
+ setShowDrawModal(true)}
+ >
+ Offer Draw
+
+ endGame("resigned")}
+ >
+ Resign
+
+ >
+ ) : (
+
+ Play Again / Lobby
+
+ )}
+
+
+
+ {showDrawModal && (
+ {
+ endGame("draw");
+ setShowDrawModal(false);
+ }}
+ onDecline={() => setShowDrawModal(false)}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/chess/screens/IdleScreen.tsx b/frontend/src/components/chess/screens/IdleScreen.tsx
index 2091e57..3c95df9 100644
--- a/frontend/src/components/chess/screens/IdleScreen.tsx
+++ b/frontend/src/components/chess/screens/IdleScreen.tsx
@@ -4,28 +4,28 @@ import { useChessStore } from "@/store/chess/useChessStore";
import { Button } from "@/components/ui/button";
export default function IdleScreen() {
- const setPhase = useChessStore((s) => s.setPhase);
+ const setPhase = useChessStore((s) => s.setPhase);
- return (
-
-
-
♟
-
Chess
-
- Play chess with your Codev friends.
-
-
-
-
- {/* TODO: [F503 - Create game lobby] emit("chess:getRooms") via socket before navigating */}
- setPhase("lobby")}>Find a Game
-
- {/* TODO: [F503 - Create game lobby] emit("chess:createRoom", { timeControl: 600 }) via socket */}
- setPhase("lobby")}>
- Create Room
-
-
-
+ return (
+
+
+
+ ♟
- );
-}
\ No newline at end of file
+
Chess
+
+ Play chess with your Codev friends.
+
+
+
+ {/* TODO: [F503 - Create game lobby] emit("chess:getRooms") via socket before navigating */}
+ setPhase("lobby")}>Find a Game
+
+ {/* TODO: [F503 - Create game lobby] emit("chess:createRoom", { timeControl: 600 }) via socket */}
+ setPhase("lobby")}>
+ Create Room
+
+
+
+ );
+}
diff --git a/frontend/src/components/chess/screens/LobbyScreen.tsx b/frontend/src/components/chess/screens/LobbyScreen.tsx
index 361720c..b4f9d95 100644
--- a/frontend/src/components/chess/screens/LobbyScreen.tsx
+++ b/frontend/src/components/chess/screens/LobbyScreen.tsx
@@ -5,40 +5,56 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function LobbyScreen() {
- const { rooms, joinRoom, setPhase } = useChessStore();
+ const { rooms, joinRoom, setPhase } = useChessStore();
- // TODO: on mount emit("chess:getRooms") and listen for chess:rooms event → setRooms()
+ // TODO: on mount emit("chess:getRooms") and listen for chess:rooms event → setRooms()
- const displayRooms = rooms.length > 0 ? rooms : [
- { id: "1", name: "Room #1", players: 1, maxPlayers: 2 as const, timeControl: 600 },
- { id: "2", name: "Room #2", players: 0, maxPlayers: 2 as const, timeControl: 300 },
- ];
+ const displayRooms =
+ rooms.length > 0
+ ? rooms
+ : [
+ {
+ id: "1",
+ name: "Room #1",
+ players: 1,
+ maxPlayers: 2 as const,
+ timeControl: 600,
+ },
+ {
+ id: "2",
+ name: "Room #2",
+ players: 0,
+ maxPlayers: 2 as const,
+ timeControl: 300,
+ },
+ ];
- return (
-
-
-
Lobby
- setPhase("idle")}>
- ← Back
-
-
-
- {displayRooms.map((room) => (
-
-
- {room.name}
-
-
-
- {room.players}/{room.maxPlayers} players · {room.timeControl / 60} min
-
- joinRoom(room)}>
- Join
-
-
-
- ))}
-
-
- );
-}
\ No newline at end of file
+ return (
+
+
+
Lobby
+ setPhase("idle")}>
+ ← Back
+
+
+
+ {displayRooms.map((room) => (
+
+
+ {room.name}
+
+
+
+ {room.players}/{room.maxPlayers} players ·{" "}
+ {room.timeControl / 60} min
+
+ joinRoom(room)}>
+ Join
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/chess/screens/RoomScreen.tsx b/frontend/src/components/chess/screens/RoomScreen.tsx
index 4c18ec3..3592b35 100644
--- a/frontend/src/components/chess/screens/RoomScreen.tsx
+++ b/frontend/src/components/chess/screens/RoomScreen.tsx
@@ -4,33 +4,33 @@ import { useChessStore } from "@/store/chess/useChessStore";
import { Button } from "@/components/ui/button";
export default function RoomScreen() {
- const { currentRoom, leaveRoom, startGame } = useChessStore();
+ const { currentRoom, leaveRoom, startGame } = useChessStore();
- // TODO: listen for socket event "chess:gameStart" → startGame(color, timeControl)
- // Remove [Dev] Start as White button when socket is wired
- return (
-
-
-
- {currentRoom?.name ?? "Room"}
-
-
- Waiting for an opponent…
-
-
+ // TODO: listen for socket event "chess:gameStart" → startGame(color, timeControl)
+ // Remove [Dev] Start as White button when socket is wired
+ return (
+
+
+
+ {currentRoom?.name ?? "Room"}
+
+
+ Waiting for an opponent…
+
+
- {/* Dev shortcut — remove before merging */}
-
startGame("w", currentRoom?.timeControl ?? 600)}
- >
- [Dev] Start as White
-
+ {/* Dev shortcut — remove before merging */}
+
startGame("w", currentRoom?.timeControl ?? 600)}
+ >
+ [Dev] Start as White
+
-
- Leave Room
-
-
- );
-}
\ No newline at end of file
+
+ Leave Room
+
+
+ );
+}
diff --git a/frontend/src/lib/chess/chess-utils.ts b/frontend/src/lib/chess/chess-utils.ts
new file mode 100644
index 0000000..da87517
--- /dev/null
+++ b/frontend/src/lib/chess/chess-utils.ts
@@ -0,0 +1,78 @@
+// lib/chess-utils.ts
+import { Chess } from "chess.js";
+import type { GameStatus } from "@/types/chess.type";
+
+export type PieceSymbol = "p" | "n" | "b" | "r" | "q";
+
+const PIECE_VALUES: Record
= {
+ p: 1,
+ n: 3,
+ b: 3,
+ r: 5,
+ q: 9,
+};
+
+const STARTING_COUNTS: Record = {
+ p: 8,
+ n: 2,
+ b: 2,
+ r: 2,
+ q: 1,
+};
+
+export function getMaterialAdvantage(fen: string) {
+ const board = fen.split(" ")[0];
+
+ // Count what is currently on the board
+ const counts = {
+ w: { p: 0, n: 0, b: 0, r: 0, q: 0 },
+ b: { p: 0, n: 0, b: 0, r: 0, q: 0 },
+ };
+
+ for (const char of board) {
+ if (char >= "a" && char <= "z" && char !== "k") {
+ counts.b[char as PieceSymbol]++;
+ } else if (char >= "A" && char <= "Z" && char !== "K") {
+ counts.w[char.toLowerCase() as PieceSymbol]++;
+ }
+ }
+
+ const wCaptured: PieceSymbol[] = []; // Black pieces captured by White
+ const bCaptured: PieceSymbol[] = []; // White pieces captured by Black
+ let wScore = 0;
+ let bScore = 0;
+
+ (Object.keys(STARTING_COUNTS) as PieceSymbol[]).forEach((piece) => {
+ // Calculate missing pieces
+ const blackLost = STARTING_COUNTS[piece] - counts.b[piece];
+ for (let i = 0; i < blackLost; i++) wCaptured.push(piece);
+
+ const whiteLost = STARTING_COUNTS[piece] - counts.w[piece];
+ for (let i = 0; i < whiteLost; i++) bCaptured.push(piece);
+
+ // Add to total on-board scores
+ wScore += counts.w[piece] * PIECE_VALUES[piece];
+ bScore += counts.b[piece] * PIECE_VALUES[piece];
+ });
+
+ // Sort the captured pieces so they look neat: Queens first, Pawns last
+ const sortOrder = { q: 1, r: 2, b: 3, n: 4, p: 5 };
+ wCaptured.sort((a, b) => sortOrder[a] - sortOrder[b]);
+ bCaptured.sort((a, b) => sortOrder[a] - sortOrder[b]);
+
+ return {
+ w: { captured: wCaptured, advantage: wScore - bScore },
+ b: { captured: bCaptured, advantage: bScore - wScore },
+ };
+}
+
+export const INITIAL_FEN =
+ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
+
+export function deriveStatus(engine: Chess): GameStatus {
+ if (engine.isCheckmate()) return "checkmate";
+ if (engine.isStalemate()) return "stalemate";
+ if (engine.isDraw()) return "draw";
+ if (engine.isCheck()) return "check";
+ return "playing";
+}
diff --git a/frontend/src/store/chess/gameSlice.ts b/frontend/src/store/chess/gameSlice.ts
new file mode 100644
index 0000000..b2041e8
--- /dev/null
+++ b/frontend/src/store/chess/gameSlice.ts
@@ -0,0 +1,111 @@
+import { StateCreator } from "zustand";
+import { Chess } from "chess.js";
+import type { ChessStore, GameSlice } from "./store.types";
+import type { Color } from "@/types/chess.type";
+import { deriveStatus, INITIAL_FEN } from "@/lib/chess/chess-utils";
+
+// Engine sits outside the state, just like before
+const chessEngine = new Chess();
+
+export const createGameSlice: StateCreator = (
+ set,
+ get,
+) => ({
+ position: INITIAL_FEN,
+ activeColor: "w" as Color,
+ status: "playing",
+ moveHistory: [],
+ lastValidation: null,
+ playerColor: null,
+ clocks: { w: 600, b: 600 },
+
+ startGame: (playerColor, timeControl) => {
+ chessEngine.reset();
+ set({
+ phase: "game", // This touches RoomSlice state, which is totally allowed!
+ playerColor,
+ position: chessEngine.fen(),
+ activeColor: "w",
+ status: "playing",
+ moveHistory: [],
+ lastValidation: null,
+ clocks: { w: timeControl, b: timeControl },
+ });
+ },
+
+ makeMove: (from, to, promotion) => {
+ const state = get();
+ if (state.status !== "playing" && state.status !== "check") return;
+
+ try {
+ const move = chessEngine.move({ from, to, promotion });
+
+ if (!move) {
+ set({ lastValidation: { valid: false, reason: "Illegal move" } });
+ return;
+ }
+ const status = deriveStatus(chessEngine);
+ const moveNumber = Math.floor(state.moveHistory.length / 2) + 1;
+
+ set({
+ position: chessEngine.fen(),
+ activeColor: chessEngine.turn() as Color,
+ status,
+ lastValidation: { valid: true },
+ moveHistory: [
+ ...state.moveHistory,
+ { san: move.san, color: move.color as Color, moveNumber },
+ ],
+ });
+ } catch {
+ set({ lastValidation: { valid: false, reason: "Illegal move" } });
+ }
+ },
+
+ setValidation: (result) => set({ lastValidation: result }),
+
+ applyServerMove: (fen, san, color, status) => {
+ chessEngine.load(fen);
+ set((state) => ({
+ position: fen,
+ status,
+ activeColor: chessEngine.turn() as Color,
+ moveHistory: [
+ ...state.moveHistory,
+ {
+ san,
+ color,
+ moveNumber: Math.floor(state.moveHistory.length / 2) + 1,
+ },
+ ],
+ }));
+ },
+
+ tickClock: (color) =>
+ set((state) => ({
+ clocks: {
+ ...state.clocks,
+ [color]: Math.max(0, state.clocks[color] - 1),
+ },
+ })),
+
+ endGame: (status) => set({ status }),
+
+ reset: () => {
+ chessEngine.reset();
+ set({
+ // Resetting Game Slice
+ position: INITIAL_FEN,
+ activeColor: "w",
+ status: "playing",
+ moveHistory: [],
+ lastValidation: null,
+ playerColor: null,
+ clocks: { w: 600, b: 600 },
+ // Resetting Room Slice
+ phase: "idle",
+ rooms: [],
+ currentRoom: null,
+ });
+ },
+});
diff --git a/frontend/src/store/chess/roomSlice.ts b/frontend/src/store/chess/roomSlice.ts
new file mode 100644
index 0000000..3b3d7f6
--- /dev/null
+++ b/frontend/src/store/chess/roomSlice.ts
@@ -0,0 +1,19 @@
+import { StateCreator } from "zustand";
+import type { ChessStore, RoomSlice } from "./store.types";
+import type { ChessPhase } from "@/types/chess.type";
+
+export const createRoomSlice: StateCreator = (
+ set,
+) => ({
+ phase: "idle" as ChessPhase,
+ rooms: [],
+ currentRoom: null,
+
+ setPhase: (phase) => set({ phase }),
+
+ setRooms: (rooms) => set({ rooms }),
+
+ joinRoom: (room) => set({ currentRoom: room, phase: "room" }),
+
+ leaveRoom: () => set({ currentRoom: null, phase: "lobby" }),
+});
diff --git a/frontend/src/store/chess/store.types.ts b/frontend/src/store/chess/store.types.ts
new file mode 100644
index 0000000..abf6109
--- /dev/null
+++ b/frontend/src/store/chess/store.types.ts
@@ -0,0 +1,43 @@
+import type {
+ ChessPhase,
+ Color,
+ GameStatus,
+ MoveEntry,
+ ValidationResult,
+ Room,
+} from "@/types/chess.type";
+
+export interface RoomSlice {
+ phase: ChessPhase;
+ rooms: Room[];
+ currentRoom: Room | null;
+ setPhase: (phase: ChessPhase) => void;
+ setRooms: (rooms: Room[]) => void;
+ joinRoom: (room: Room) => void;
+ leaveRoom: () => void;
+}
+
+export interface GameSlice {
+ position: string;
+ activeColor: Color;
+ status: GameStatus;
+ moveHistory: MoveEntry[];
+ lastValidation: ValidationResult | null;
+ playerColor: Color | null;
+ clocks: { w: number; b: number };
+ startGame: (playerColor: Color, timeControl: number) => void;
+ makeMove: (from: string, to: string, promotion?: string) => void;
+ setValidation: (result: ValidationResult) => void;
+ applyServerMove: (
+ fen: string,
+ san: string,
+ color: Color,
+ status: GameStatus,
+ ) => void;
+ tickClock: (color: Color) => void;
+ endGame: (status: GameStatus) => void;
+ reset: () => void;
+}
+
+// Stitching the interfaces together to make the Master Store Type
+export type ChessStore = RoomSlice & GameSlice;
diff --git a/frontend/src/store/chess/useChessStore.ts b/frontend/src/store/chess/useChessStore.ts
index a788819..817cfb1 100644
--- a/frontend/src/store/chess/useChessStore.ts
+++ b/frontend/src/store/chess/useChessStore.ts
@@ -1,186 +1,9 @@
import { create } from "zustand";
-import { Chess } from "chess.js";
-
-// Types
-
-export type ChessPhase = "idle" | "lobby" | "room" | "game";
-export type Color = "w" | "b";
-export type GameStatus =
- | "playing"
- | "check"
- | "checkmate"
- | "stalemate"
- | "draw"
- | "resigned";
-
-export interface MoveEntry {
- san: string;
- color: Color;
- moveNumber: number;
-}
-
-export interface ValidationResult {
- valid: boolean;
- reason?: string;
-}
-
-export interface Room {
- id: string;
- name: string;
- players: number;
- maxPlayers: 2;
- timeControl: number;
-}
-
-interface ChessState {
- phase: ChessPhase;
- rooms: Room[];
- currentRoom: Room | null;
- position: string;
- activeColor: Color;
- status: GameStatus;
- moveHistory: MoveEntry[];
- lastValidation: ValidationResult | null;
- playerColor: Color | null;
- clocks: { w: number; b: number };
- setPhase: (phase: ChessPhase) => void;
- setRooms: (rooms: Room[]) => void;
- joinRoom: (room: Room) => void;
- leaveRoom: () => void;
- startGame: (playerColor: Color, timeControl: number) => void;
- makeMove: (from: string, to: string, promotion?: string) => void;
- setValidation: (result: ValidationResult) => void;
- applyServerMove: (fen: string, san: string, color: Color, status: GameStatus) => void;
- tickClock: (color: Color) => void;
- endGame: (status: GameStatus) => void;
- reset: () => void;
-}
-
-// Constants
-
-const INITIAL_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
-
-const initialState = {
- phase: "idle" as ChessPhase,
- rooms: [],
- currentRoom: null,
- position: INITIAL_FEN,
- activeColor: "w" as Color,
- status: "playing" as GameStatus,
- moveHistory: [],
- lastValidation: null,
- playerColor: null,
- clocks: { w: 600, b: 600 },
-};
-
-// Chess engine (outside store)
-
-const chessEngine = new Chess();
-
-// Helpers
-
-function deriveStatus(engine: Chess): GameStatus {
- console.log("deriveStatus:", {
- isCheckmate: engine.isCheckmate(),
- isStalemate: engine.isStalemate(),
- isDraw: engine.isDraw(),
- isCheck: engine.isCheck(),
- });
- if (engine.isCheckmate()) return "checkmate";
- if (engine.isStalemate()) return "stalemate";
- if (engine.isDraw()) return "draw";
- if (engine.isCheck()) return "check";
- return "playing";
-}
-
-// Store
-// TODO: [F503 - Handle real-time moves] Add socket: Socket | null and setSocket() here
-// setSocket() registers all chess:* socket events (see F503 PR10)
-export const useChessStore = create((set, get) => ({
- ...initialState,
-
- setPhase: (phase) => set({ phase }),
-
- setRooms: (rooms) => set({ rooms }),
-
- joinRoom: (room) => set({ currentRoom: room, phase: "room" }),
-
- leaveRoom: () => set({ currentRoom: null, phase: "lobby" }),
-
- startGame: (playerColor, timeControl) => {
- chessEngine.reset();
- set({
- phase: "game",
- playerColor,
- position: chessEngine.fen(),
- activeColor: "w",
- status: "playing",
- moveHistory: [],
- lastValidation: null,
- clocks: { w: timeControl, b: timeControl },
- });
- },
-
- makeMove: (from, to, promotion) => {
- const state = get();
- if (state.status !== "playing" && state.status !== "check") return;
-
- try {
- const move = chessEngine.move({ from, to, promotion });
-
- if (!move) {
- set({ lastValidation: { valid: false, reason: "Illegal move" } });
- return;
- }
- const status = deriveStatus(chessEngine);
- const moveNumber = Math.floor(state.moveHistory.length / 2) + 1;
-
- set({
- position: chessEngine.fen(),
- activeColor: chessEngine.turn() as Color,
- status,
- lastValidation: { valid: true },
- moveHistory: [
- ...state.moveHistory,
- { san: move.san, color: move.color as Color, moveNumber },
- ],
- });
- } catch {
- set({ lastValidation: { valid: false, reason: "Illegal move" } });
- }
- },
-
- setValidation: (result) => set({ lastValidation: result }),
- // TODO: [F503 - Handle real-time moves] Called when opponent's move arrives via socket.on("chess:moveMade"
- applyServerMove: (fen, san, color, status) => {
- chessEngine.load(fen);
- set((state) => ({
- position: fen,
- status,
- activeColor: chessEngine.turn() as Color,
- moveHistory: [
- ...state.moveHistory,
- {
- san,
- color,
- moveNumber: Math.floor(state.moveHistory.length / 2) + 1,
- },
- ],
- }));
- },
-
- tickClock: (color) =>
- set((state) => ({
- clocks: {
- ...state.clocks,
- [color]: Math.max(0, state.clocks[color] - 1),
- },
- })),
-
- endGame: (status) => set({ status }),
-
- reset: () => {
- chessEngine.reset();
- set({ ...initialState });
- },
-}));
\ No newline at end of file
+import type { ChessStore } from "./store.types";
+import { createRoomSlice } from "./roomSlice";
+import { createGameSlice } from "./gameSlice";
+
+export const useChessStore = create()((...a) => ({
+ ...createRoomSlice(...a),
+ ...createGameSlice(...a),
+}));
diff --git a/frontend/src/types/chess.type.ts b/frontend/src/types/chess.type.ts
new file mode 100644
index 0000000..cfbb91b
--- /dev/null
+++ b/frontend/src/types/chess.type.ts
@@ -0,0 +1,57 @@
+export type ChessPhase = "idle" | "lobby" | "room" | "game";
+export type Color = "w" | "b";
+export type GameStatus =
+ | "playing"
+ | "check"
+ | "checkmate"
+ | "stalemate"
+ | "draw"
+ | "resigned";
+
+export interface MoveEntry {
+ san: string;
+ color: Color;
+ moveNumber: number;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ reason?: string;
+}
+
+export interface Room {
+ id: string;
+ name: string;
+ players: number;
+ maxPlayers: 2;
+ timeControl: number;
+}
+
+export interface ChessState {
+ phase: ChessPhase;
+ rooms: Room[];
+ currentRoom: Room | null;
+ position: string;
+ activeColor: Color;
+ status: GameStatus;
+ moveHistory: MoveEntry[];
+ lastValidation: ValidationResult | null;
+ playerColor: Color | null;
+ clocks: { w: number; b: number };
+ setPhase: (phase: ChessPhase) => void;
+ setRooms: (rooms: Room[]) => void;
+ joinRoom: (room: Room) => void;
+ leaveRoom: () => void;
+ startGame: (playerColor: Color, timeControl: number) => void;
+ makeMove: (from: string, to: string, promotion?: string) => void;
+ setValidation: (result: ValidationResult) => void;
+ applyServerMove: (
+ fen: string,
+ san: string,
+ color: Color,
+ status: GameStatus,
+ ) => void;
+ tickClock: (color: Color) => void;
+ endGame: (status: GameStatus) => void;
+ reset: () => void;
+}