diff --git a/react-ystemandchess/src/core/types/chess.d.ts b/react-ystemandchess/src/core/types/chess.d.ts
index 721e83c4..3c5f597c 100644
--- a/react-ystemandchess/src/core/types/chess.d.ts
+++ b/react-ystemandchess/src/core/types/chess.d.ts
@@ -1,3 +1,4 @@
+export type PieceSymbol = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
export type PlayerColor = "white" | "black";
export type GameMode = "regular" | "puzzle" | "lesson";
export type UserRole = "mentor" | "student" | "host" | "guest";
diff --git a/react-ystemandchess/src/core/types/goals.ts b/react-ystemandchess/src/core/types/goals.ts
new file mode 100644
index 00000000..0e8ae4d7
--- /dev/null
+++ b/react-ystemandchess/src/core/types/goals.ts
@@ -0,0 +1,117 @@
+import type { Chess } from 'chess.js';
+import { PieceSymbol } from './chess';
+
+export type Actor = 'player' | 'opponent';
+
+export type AtomicGoal =
+ | {
+ type: 'PROMOTION';
+ min?: number;
+ piece?: 'q' | 'r' | 'b' | 'n';
+ by?: Actor; // Defaults to 'player'
+ }
+ | {
+ type: 'CAPTURE';
+ min?: number;
+ piece?: 'p' | 'n' | 'b' | 'r' | 'q';
+ square?: string;
+ by?: Actor;
+ }
+ | {
+ type: 'CHECKMATE';
+ by?: Actor;
+ }
+ | {
+ type: 'PAWN_DOUBLE_PUSH';
+ min?: number;
+ by?: Actor;
+ }
+ | {
+ type: 'ALL_PAWNS_MOVED';
+ by?: Actor;
+ }
+ | {
+ type: 'MATERIAL_ADVANTAGE';
+ threshold: number;
+ };
+
+
+export type CompositeGoal =
+ | {
+ type: 'AND';
+ goals: Goal[];
+ }
+ | {
+ type: 'OR';
+ goals: Goal[];
+ }
+
+
+export type SequenceGoal = {
+ type: 'SEQUENCE';
+ goals: AtomicGoal[];
+};
+
+export type Goal = AtomicGoal | CompositeGoal | SequenceGoal;
+
+export type OpponentConstraint =
+ | {
+ type: 'AVOID_SQUARES';
+ squares: string[];
+ }
+ | {
+ type: 'AVOID_CAPTURING';
+ pieces?: ('p' | 'n' | 'b' | 'r' | 'q' | 'k')[]; // If omitted, avoid all captures
+ }
+ | {
+ type: 'AVOID_CHECKING';
+ }
+ | {
+ type: 'ONLY_MOVE_PIECES';
+ pieces: ('p' | 'n' | 'b' | 'r' | 'q' | 'k')[];
+ }
+ | {
+ type: 'STAY_IN_AREA';
+ minRank?: number; // e.g., 6 = stay on ranks 6-8
+ maxRank?: number; // e.g., 3 = stay on ranks 1-3
+ }
+ | {
+ type: 'DONT_MOVE_FROM';
+ squares: string[]; // Don't move pieces away from these squares
+ };
+
+export interface MoveEvent {
+ san: string;
+ from: string;
+ to: string;
+ piece: PieceSymbol;
+ captured?: PieceSymbol;
+ promotion?: PieceSymbol;
+ check: boolean;
+ checkmate: boolean;
+ doublePawnPush: boolean;
+ enPassant: boolean;
+ castling?: 'kingside' | 'queenside';
+ fen: string;
+ by?: Actor;
+}
+
+export interface EvaluationContext {
+ events: MoveEvent[];
+ currentGame: Chess;
+ startFen: string;
+ currentFen: string;
+ playerColor: 'white' | 'black';
+ moveCount: number;
+}
+
+export interface LessonData {
+ lessonNum: number;
+ name: string;
+ startFen: string;
+ info: string;
+ solution?: string;
+ goal?: Goal;
+ maxMoves?: number;
+ opponentConstraints?: OpponentConstraint[];
+}
diff --git a/react-ystemandchess/src/core/utils/eventLogger.ts b/react-ystemandchess/src/core/utils/eventLogger.ts
new file mode 100644
index 00000000..02175f4c
--- /dev/null
+++ b/react-ystemandchess/src/core/utils/eventLogger.ts
@@ -0,0 +1,92 @@
+import { MoveEvent } from '../types/goals';
+import { PieceSymbol } from '../types/chess';
+
+type ChessJsMove = {
+ san: string;
+ from: string;
+ to: string;
+ piece: PieceSymbol;
+ captured?: PieceSymbol;
+ promotion?: PieceSymbol;
+ flags: string;
+};
+
+export function createMoveEvent(
+ move: ChessJsMove,
+ afterFen: string,
+ isPlayerMove: boolean
+): MoveEvent {
+ const fromRank = Number(move.from[1]);
+ const toRank = Number(move.to[1]);
+
+ const doublePawnPush =
+ move.piece === 'p' &&
+ Math.abs(fromRank - toRank) === 2;
+
+ return {
+ san: move.san,
+ from: move.from,
+ to: move.to,
+ piece: move.piece,
+ captured: move.captured,
+ promotion: move.promotion,
+ check: move.san.includes('+'),
+ checkmate: move.san.includes('#'),
+ doublePawnPush,
+ enPassant: move.flags.includes('e'),
+ castling: move.flags.includes('k')
+ ? 'kingside'
+ : move.flags.includes('q')
+ ? 'queenside'
+ : undefined,
+ fen: afterFen,
+ by: isPlayerMove ? 'player' : 'opponent'
+ };
+}
+
+export class EventLog {
+ private events: MoveEvent[] = [];
+
+ addMove(event: MoveEvent) {
+ this.events.push(event);
+ }
+
+ getEvents(): MoveEvent[] {
+ return [...this.events];
+ }
+
+ clear() {
+ this.events = [];
+ }
+
+ getPromotions(): MoveEvent[] {
+ return this.events.filter(e => e.promotion);
+ }
+
+ getCaptures(): MoveEvent[] {
+ return this.events.filter(e => e.captured);
+ }
+
+ getDoublePawnPushes(): MoveEvent[] {
+ return this.events.filter(e => e.doublePawnPush);
+ }
+
+ hasCheckmate(): boolean {
+ return this.events.some(e => e.checkmate);
+ }
+
+ hasSequence(
+ checkA: (e: MoveEvent) => boolean,
+ checkB: (e: MoveEvent) => boolean
+ ): boolean {
+ let foundA = false;
+ for (const event of this.events) {
+ if (!foundA && checkA(event)) {
+ foundA = true;
+ } else if (foundA && checkB(event)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/react-ystemandchess/src/core/utils/goalEvaluator.ts b/react-ystemandchess/src/core/utils/goalEvaluator.ts
new file mode 100644
index 00000000..4c4510d7
--- /dev/null
+++ b/react-ystemandchess/src/core/utils/goalEvaluator.ts
@@ -0,0 +1,250 @@
+import { Chess } from 'chess.js';
+import { Goal, AtomicGoal, Actor, EvaluationContext } from '../types/goals';
+
+/**
+ * Main goal evaluation function
+ * Returns true if the goal is achieved
+ */
+export function evaluateGoal(goal: Goal, context: EvaluationContext): boolean {
+ console.log('[Goal Evaluator] Checking goal:', goal.type);
+
+ switch (goal.type) {
+ case 'PROMOTION':
+ return evaluatePromotion(goal, context);
+
+ case 'CAPTURE':
+ return evaluateCapture(goal, context);
+
+ case 'CHECKMATE':
+ return evaluateCheckmate(context);
+
+ case 'PAWN_DOUBLE_PUSH':
+ return evaluatePawnDoublePush(goal, context);
+
+ case 'ALL_PAWNS_MOVED':
+ return evaluateAllPawnsMoved(context);
+
+ case 'MATERIAL_ADVANTAGE':
+ return evaluateMaterialAdvantage(goal, context);
+
+ case 'AND':
+ return evaluateAND(goal, context);
+
+ case 'OR':
+ return evaluateOR(goal, context);
+
+ case 'SEQUENCE':
+ return evaluateSEQUENCE(goal, context);
+
+ default:
+ console.error('[Goal Evaluator] Unknown goal type:', (goal as any).type);
+ return false;
+ }
+}
+
+// ============================================
+// ATOMIC GOAL EVALUATORS
+// ============================================
+
+function evaluatePromotion(
+ goal: { type: 'PROMOTION'; min?: number; piece?: string; by?: Actor },
+ context: EvaluationContext
+): boolean {
+ const minRequired = goal.min ?? 1;
+ const by = goal.by ?? 'player';
+
+ // Filter by actor (player vs opponent)
+ const relevantEvents = context.events.filter((e, i) => {
+ const isPlayerMove = i % 2 === 0; // Even indices = player moves
+ return by === 'player' ? isPlayerMove : !isPlayerMove;
+ });
+
+ let promotionCount = relevantEvents.filter(e => e.promotion).length;
+
+ // Filter by piece type if specified
+ if (goal.piece) {
+ promotionCount = relevantEvents.filter(
+ e => e.promotion === goal.piece
+ ).length;
+ }
+
+ console.log(`[PROMOTION] Required: ${minRequired}, Found: ${promotionCount}, By: ${by}`);
+ return promotionCount >= minRequired;
+}
+
+function evaluateCapture(
+ goal: { type: 'CAPTURE'; min?: number; piece?: string; square?: string; by?: Actor },
+ context: EvaluationContext
+): boolean {
+ const minRequired = goal.min ?? 1;
+ const by = goal.by ?? 'player';
+
+ const relevantEvents = context.events.filter((e, i) => {
+ const isPlayerMove = i % 2 === 0;
+ return by === 'player' ? isPlayerMove : !isPlayerMove;
+ });
+
+ let captures = relevantEvents.filter(e => e.captured);
+
+ // Filter by captured piece type
+ if (goal.piece) {
+ captures = captures.filter(e => e.captured === goal.piece);
+ }
+
+ // Filter by capture square
+ if (goal.square) {
+ captures = captures.filter(e => e.to === goal.square);
+ }
+
+ console.log(`[CAPTURE] Required: ${minRequired}, Found: ${captures.length}`);
+ return captures.length >= minRequired;
+}
+
+function evaluateCheckmate(context: EvaluationContext): boolean {
+ const isCheckmate = context.currentGame.isCheckmate();
+
+ if (isCheckmate) {
+ // Verify the correct side is checkmated
+ const turn = context.currentGame.turn(); // Who's turn (they're mated)
+ const playerWon =
+ (context.playerColor === 'white' && turn === 'b') ||
+ (context.playerColor === 'black' && turn === 'w');
+
+ console.log(`[CHECKMATE] Player won: ${playerWon}`);
+ return playerWon;
+ }
+
+ return false;
+}
+
+function evaluatePawnDoublePush(
+ goal: { type: 'PAWN_DOUBLE_PUSH'; min?: number },
+ context: EvaluationContext
+): boolean {
+ const minRequired = goal.min ?? 1;
+ const doublePushCount = context.events.filter(e => e.doublePawnPush).length;
+
+ console.log(`[PAWN_DOUBLE_PUSH] Required: ${minRequired}, Found: ${doublePushCount}`);
+ return doublePushCount >= minRequired;
+}
+
+function evaluateAllPawnsMoved(context: EvaluationContext): boolean {
+ // Get starting pawn positions
+ const startGame = new Chess(context.startFen);
+ const startPawns = getPawnPositions(startGame, context.playerColor);
+
+ // Get current pawn positions
+ const currentPawns = getPawnPositions(context.currentGame, context.playerColor);
+
+ // Check if all starting pawns have moved or been captured/promoted
+ const allMoved = startPawns.every(startSquare => {
+ return !currentPawns.includes(startSquare);
+ });
+
+ console.log(`[ALL_PAWNS_MOVED] Start pawns: ${startPawns}, Current: ${currentPawns}, All moved: ${allMoved}`);
+ return allMoved;
+}
+
+function evaluateMaterialAdvantage(
+ goal: { type: 'MATERIAL_ADVANTAGE'; threshold: number },
+ context: EvaluationContext
+): boolean {
+ const materialDiff = calculateMaterialDifference(context.currentFen);
+
+ // Positive = white ahead, negative = black ahead
+ const advantage = context.playerColor === 'white' ? materialDiff : -materialDiff;
+
+ console.log(`[MATERIAL_ADVANTAGE] Required: ${goal.threshold}, Current: ${advantage}`);
+ return advantage >= goal.threshold;
+}
+
+// ============================================
+// COMPOSITE GOAL EVALUATORS
+// ============================================
+
+function evaluateAND(
+ goal: { type: 'AND'; goals: Goal[] },
+ context: EvaluationContext
+): boolean {
+ const results = goal.goals.map(g => evaluateGoal(g, context));
+ const allTrue = results.every(r => r);
+
+ console.log(`[AND] Results:`, results, `=> ${allTrue}`);
+ return allTrue;
+}
+
+function evaluateOR(
+ goal: { type: 'OR'; goals: Goal[] },
+ context: EvaluationContext
+): boolean {
+ const results = goal.goals.map(g => evaluateGoal(g, context));
+ const anyTrue = results.some(r => r);
+
+ console.log(`[OR] Results:`, results, `=> ${anyTrue}`);
+ return anyTrue;
+}
+
+function evaluateSEQUENCE(
+ goal: { type: 'SEQUENCE'; goals: AtomicGoal[] },
+ context: EvaluationContext
+): boolean {
+ let cursor = 0;
+
+ for (const atomicGoal of goal.goals) {
+ let satisfiedAt = -1;
+
+ for (let i = cursor; i < context.events.length; i++) {
+ const partialContext: EvaluationContext = {
+ ...context,
+ events: context.events.slice(0, i + 1)
+ };
+
+ if (evaluateGoal(atomicGoal, partialContext)) {
+ satisfiedAt = i;
+ break;
+ }
+ }
+
+ if (satisfiedAt === -1) {
+ return false;
+ }
+
+ cursor = satisfiedAt + 1;
+ }
+
+ return true;
+}
+
+// ============================================
+// HELPER FUNCTIONS
+// ============================================
+
+function getPawnPositions(game: Chess, color: 'white' | 'black'): string[] {
+ const board = game.board();
+ const positions: string[] = [];
+
+ for (let row = 0; row < 8; row++) {
+ for (let col = 0; col < 8; col++) {
+ const square = board[row][col];
+ if (square && square.type === 'p' && square.color === color[0]) {
+ const file = String.fromCharCode(97 + col); // a-h
+ const rank = 8 - row; // 1-8
+ positions.push(`${file}${rank}`);
+ }
+ }
+ }
+
+ return positions;
+}
+
+function calculateMaterialDifference(fen: string): number {
+ const pieceValues: { [key: string]: number } = {
+ 'p': 1, 'n': 3, 'b': 3, 'r': 5, 'q': 9,
+ 'P': -1, 'N': -3, 'B': -3, 'R': -5, 'Q': -9
+ };
+
+ return fen
+ .split(' ')[0]
+ .split('')
+ .reduce((sum, c) => sum + (pieceValues[c] ?? 0), 0);
+}
diff --git a/react-ystemandchess/src/core/utils/opponentConstraints.ts b/react-ystemandchess/src/core/utils/opponentConstraints.ts
new file mode 100644
index 00000000..4478d8a6
--- /dev/null
+++ b/react-ystemandchess/src/core/utils/opponentConstraints.ts
@@ -0,0 +1,87 @@
+import { Chess } from 'chess.js';
+import { OpponentConstraint } from '../types/goals';
+
+export function getConstrainedMoves(
+ fen: string,
+ constraints: OpponentConstraint[]
+): any[] {
+ const game = new Chess(fen);
+ let moves = game.moves({ verbose: true });
+
+ // Apply each constraint as a filter
+ for (const constraint of constraints) {
+ moves = applyConstraint(moves, constraint, game);
+ }
+
+ return moves;
+}
+
+function applyConstraint(
+ moves: any[],
+ constraint: OpponentConstraint,
+ game: Chess
+): any[] {
+ switch (constraint.type) {
+ case 'AVOID_SQUARES':
+ return moves.filter(m => !constraint.squares.includes(m.to));
+
+ case 'AVOID_CAPTURING':
+ if (constraint.pieces) {
+ // Only avoid capturing specific pieces
+ return moves.filter(m =>
+ !m.captured || !constraint.pieces!.includes(m.captured)
+ );
+ }
+ // Avoid all captures
+ return moves.filter(m => !m.captured);
+
+ case 'AVOID_CHECKING':
+ return moves.filter(m =>
+ !m.san.includes('+') && !m.san.includes('#')
+ );
+
+ case 'ONLY_MOVE_PIECES':
+ return moves.filter(m => constraint.pieces.includes(m.piece));
+
+ case 'STAY_IN_AREA':
+ return moves.filter(m => {
+ const toRank = parseInt(m.to[1]);
+ if (constraint.minRank && toRank < constraint.minRank) return false;
+ if (constraint.maxRank && toRank > constraint.maxRank) return false;
+ return true;
+ });
+
+ case 'DONT_MOVE_FROM':
+ return moves.filter(m => !constraint.squares.includes(m.from));
+
+ default:
+ return moves;
+ }
+}
+
+export function getConstrainedMove(
+ fen: string,
+ constraints: OpponentConstraint[]
+): { from: string; to: string; promotion?: string } | null {
+ const constrainedMoves = getConstrainedMoves(fen, constraints);
+
+ if (constrainedMoves.length === 0) {
+ console.warn('[Constraints] No moves satisfy constraints, using any legal move');
+ const game = new Chess(fen);
+ const allMoves = game.moves({ verbose: true });
+ if (allMoves.length === 0) return null;
+ const fallback = allMoves[Math.floor(Math.random() * allMoves.length)];
+ return { from: fallback.from, to: fallback.to, promotion: fallback.promotion };
+ }
+
+ // Random selection from constrained moves
+ const chosen = constrainedMoves[Math.floor(Math.random() * constrainedMoves.length)];
+
+ console.log('[Constraints] Chose move:', chosen.san, 'from', constrainedMoves.length, 'options');
+
+ return {
+ from: chosen.from,
+ to: chosen.to,
+ promotion: chosen.promotion
+ };
+}
diff --git a/react-ystemandchess/src/features/lessons/lessons-main/Scenarios.js b/react-ystemandchess/src/features/lessons/lessons-main/Scenarios.js
index 4292e1f4..421a0156 100644
--- a/react-ystemandchess/src/features/lessons/lessons-main/Scenarios.js
+++ b/react-ystemandchess/src/features/lessons/lessons-main/Scenarios.js
@@ -4,15 +4,25 @@ export const scenariosArray = [
subSections: [
{
name: 'Basic',
- fen: '7k/8/8/P7/8/5p2/8/K7 w - - 0 1',
- info: `Pawns move one square only.
- But when they reach the other side of the board, they become a stronger piece!`,
+ fen: '7k/8/8/P7/8/5p2/8/K7 w - - 0 1', // startFen
+ info: 'Pawns move one square only. But when they reach the other side of the board, they become a stronger piece!',
+ solution: null,
+ goal: { type: 'PROMOTION', min: 1 },
+ opponentConstraints: [
+ { type: 'AVOID_CAPTURING' },
+ { type: 'AVOID_SQUARES', squares: ['a6', 'a7', 'a8'] }
+ ],
},
{
name: 'Capture',
fen: '8/3p4/2p5/3p4/8/4P3/8/K6k w - - 0 1',
- info: `Pawns move forward,
- but capture diagonally!`,
+ info: 'Pawns move forward, but capture diagonally!',
+ solution: null,
+ goal: { type: 'CAPTURE', min: 1 },
+ opponentConstraints: [
+ { type: 'AVOID_CAPTURING' },
+ { type: 'DONT_MOVE_FROM', squares: ['d7'] }
+ ],
},
{
name: 'Training 1',
@@ -22,18 +32,18 @@ export const scenariosArray = [
{
name: 'Training 2',
fen: '2p5/3p4/1p2p3/1p1p4/2p5/3P4/8/K6k w - - 0 1',
- info: `Capture, then promote!`,
+ info: 'Capture, then promote!',
},
{
name: 'Traning 3',
fen: '8/8/8/1pp1p3/3p2p1/P1PP3P/8/K6k w - - 0 1',
- info: `Use all the pawns!
- No need to promote.`,
+ info: 'Use all the pawns! No need to promote.',
},
{
name: 'Special Move',
fen: '8/8/3p4/8/8/8/4P3/K6k w - - 0 1',
- info: `A pawn on the second rank can move 2 squares at once!`,
+ info: 'A pawn on the second rank can move 2 squares at once!',
+ solution: "e4",
},
],
},
@@ -958,8 +968,9 @@ export const scenariosArray = [
subSections: [
{
name: "Double Check Introduction",
- fen: "k1q5/1pp5/8/8/N7/8/8/R5K1 w - - 0 1",
- info: "Checkmate the opponent in 2 moves. A Double Check is when two pieces are delivering check simultaneously. A Double Check is generally more powerful than a normal check, because the opponent can only respond with a king move. (The pieces that are delivering check cannot both be captured or blocked with one move.)"
+ fen: "8/k1p5/1p6/5B2/N7/8/8/R5K1 w - - 0 1",
+ info: "Checkmate the opponent in 2 moves. A Double Check is when two pieces are delivering check simultaneously. A Double Check is generally more powerful than a normal check, because the opponent can only respond with a king move. (The pieces that are delivering check cannot both be captured or blocked with one move.)",
+ solution: "Nc5+ Kb8 Nd7#",
},
{
name: "Double Check #1",
diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx
index b909a907..c6323e78 100644
--- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx
+++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx
@@ -1,30 +1,27 @@
-import {
- render,
- screen,
- fireEvent,
- waitFor,
- act,
-} from "@testing-library/react";
-import LessonOverlay from "./Lesson-overlay";
-import { MemoryRouter } from "react-router";
-import * as router from "react-router";
-import { useLessonManager } from "./hooks/useLessonManager";
-import { useChessGameLogic } from "./hooks/useChessGameLogic";
-import { useChessSocket } from "./hooks/useChessSocket";
-import { useTimeTracking } from "./hooks/useTimeTracking";
+// All jest.mock() calls at the top, before any imports. This ensures proper hoisting
+
+// Mock socket.io-client with a factory that creates fresh socket instances
+jest.mock("socket.io-client");
-// --- Mocks ---
+// Mock environment
+jest.mock("../../../../environments/environment");
+
+// Mock utility modules
+jest.mock("../../../../core/utils/goalEvaluator");
+jest.mock("../../../../core/utils/eventLogger");
+jest.mock("../../../../core/utils/opponentConstraints");
// Mock react-cookie
jest.mock("react-cookie", () => ({
- useCookies: () => [
+ useCookies: jest.fn(() => [
{ login: { studentId: "student123" } },
jest.fn(),
jest.fn(),
- ],
+ ]),
+ Cookies: jest.fn(),
}));
-// Mock react-router
+// Mock react-router
const mockNavigate = jest.fn();
jest.mock("react-router", () => ({
...jest.requireActual("react-router"),
@@ -32,54 +29,111 @@ jest.mock("react-router", () => ({
useLocation: jest.fn(),
}));
-// Mock child components
+// Mock components
jest.mock("../../../../components/ChessBoard/ChessBoard", () => {
- const { forwardRef, useImperativeHandle } = require("react");
- return forwardRef((props: any, ref: any) => {
- useImperativeHandle(ref, () => ({
- flip: jest.fn(),
- highlightMove: jest.fn(),
- setOrientation: jest.fn(),
- handlePromotion: jest.fn(),
- reset: jest.fn(),
- undo: jest.fn(),
- }));
- return (
-
-
ChessBoard Mock
-
-
-
- {props.disabled ? "true" : "false"}
+ const React = require("react");
+ return {
+ __esModule: true,
+ default: React.forwardRef((props: any, ref: any) => {
+ React.useImperativeHandle(ref, () => ({
+ flip: jest.fn(),
+ highlightMove: jest.fn(),
+ setOrientation: jest.fn(),
+ handlePromotion: jest.fn(),
+ reset: jest.fn(),
+ undo: jest.fn(),
+ setPosition: jest.fn(),
+ clearHighlights: jest.fn(),
+ }));
+ return (
+
+
ChessBoard Mock
+
+
+
+ {props.disabled ? "true" : "false"}
+
-
- );
- });
+ );
+ }),
+ };
});
-jest.mock("../move-tracker/MoveTracker", () => () => (
-
MoveTracker Mock
-));
+jest.mock("../move-tracker/MoveTracker", () => ({
+ __esModule: true,
+ default: () =>
MoveTracker Mock
,
+}));
-jest.mock("../../lessons-main/PromotionPopup", () => () => (
-
PromotionPopup Mock
-));
+jest.mock("../../lessons-main/PromotionPopup", () => ({
+ __esModule: true,
+ default: () =>
PromotionPopup Mock
,
+}));
// Mock custom hooks
jest.mock("./hooks/useLessonManager");
jest.mock("./hooks/useChessGameLogic");
jest.mock("./hooks/useChessSocket");
-jest.mock("./hooks/useTimeTracking");
+jest.mock("./hooks/useTimeTracking", () => ({
+ useTimeTracking: jest.fn(),
+}));
+
+// Now import everything
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import * as router from "react-router";
+import { io } from "socket.io-client";
+import LessonOverlay from "./Lesson-overlay";
+import { useLessonManager } from "./hooks/useLessonManager";
+import { useChessGameLogic } from "./hooks/useChessGameLogic";
+import { useChessSocket } from "./hooks/useChessSocket";
+
+// Setup mocks after imports
+const mockIo = io as jest.MockedFunction
;
+
+// Import mocked modules
+const goalEvaluator = require("../../../../core/utils/goalEvaluator");
+const eventLogger = require("../../../../core/utils/eventLogger");
+const { environment } = require("../../../../environments/environment");
+
+// Create socket factory
+const createMockSocket = () => {
+ const socket = {
+ connected: true,
+ on: jest.fn(),
+ emit: jest.fn(),
+ disconnect: jest.fn(),
+ off: jest.fn(),
+ };
+
+ // Make methods chainable
+ socket.on.mockReturnValue(socket);
+ socket.emit.mockReturnValue(socket);
+ socket.disconnect.mockReturnValue(socket);
+ socket.off.mockReturnValue(socket);
+
+ return socket;
+};
+
+// Configure io mock
+mockIo.mockImplementation(() => createMockSocket() as any);
+
+// Configure environment mock
+Object.assign(environment, {
+ urls: {
+ chessServerURL: "http://localhost:3000",
+ stockfishServerURL: "http://localhost:3001",
+ }
+});
describe("LessonOverlay", () => {
let mockUseLessonManager: any;
@@ -90,18 +144,55 @@ describe("LessonOverlay", () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Re-setup useCookies mock after clearAllMocks
+ const { useCookies } = require("react-cookie");
+ (useCookies as jest.Mock).mockReturnValue([
+ { login: { studentId: "student123" } },
+ jest.fn(),
+ jest.fn(),
+ ]);
+
+ // Reset io mock to return fresh sockets
+ mockIo.mockImplementation(() => createMockSocket() as any);
+
(router.useLocation as jest.Mock).mockReturnValue({
state: { piece: "Rook", lessonNum: 0 },
});
+ // Reset goal evaluator mock
+ goalEvaluator.evaluateGoal.mockReturnValue(false);
+
+ // Setup EventLog mock
+ eventLogger.EventLog.mockImplementation(() => ({
+ addMove: jest.fn(),
+ getEvents: jest.fn().mockReturnValue([]),
+ clear: jest.fn(),
+ getPromotions: jest.fn().mockReturnValue([]),
+ getCaptures: jest.fn().mockReturnValue([]),
+ }));
+
+ eventLogger.createMoveEvent.mockImplementation((move: any, fen: string, isPlayer: boolean) => ({
+ san: move.san || 'e4',
+ from: move.from,
+ to: move.to,
+ piece: move.piece || 'p',
+ promotion: move.promotion,
+ check: false,
+ checkmate: false,
+ doublePawnPush: false,
+ enPassant: false,
+ fen: fen,
+ by: isPlayer ? 'player' : 'opponent',
+ }));
+
// Setup useLessonManager mock
mockUseLessonManager = {
lessonData: {
startFen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
- endFen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 2",
info: "get a winning position",
name: "Test Lesson Name",
moves: [],
+ goal: { type: "CHECKMATE" },
},
lessonNum: 0,
completedNum: 0,
@@ -150,9 +241,6 @@ describe("LessonOverlay", () => {
);
- // Check loading popup appears initially (or mock resolves fast)
- // In useEffect, refreshProgress is called.
-
await waitFor(() => {
expect(screen.getByText(/Test Lesson Name/)).toBeInTheDocument();
});
@@ -164,34 +252,10 @@ describe("LessonOverlay", () => {
expect(screen.getByTestId("move-tracker")).toBeInTheDocument();
});
- test("initializes lesson on server after delay", async () => {
- render(
-
-
-
- );
-
- // Wait for useEffect timeout
- await waitFor(
- () => {
- expect(mockSocket.setGameStateWithColor).toHaveBeenCalled();
- },
- { timeout: 2000 }
- );
-
- // Check arguments
- expect(mockSocket.setGameStateWithColor).toHaveBeenCalledWith(
- mockUseLessonManager.lessonData.startFen,
- "white", // derived from startFen
- mockUseLessonManager.lessonData.info
- );
- });
-
test("handles navigation buttons", async () => {
- // Override mock to make Next button active (completedNum > lessonNum)
(useLessonManager as jest.Mock).mockReturnValue({
...mockUseLessonManager,
- completedNum: 1, // lessonNum is 0, so 0 < 1, Next is active
+ completedNum: 1,
});
const { unmount } = render(
@@ -203,7 +267,6 @@ describe("LessonOverlay", () => {
const nextButton = await screen.findByText("Next");
const btn = nextButton.closest("button");
- // Ensure we are clicking the button
if (btn) {
fireEvent.click(btn);
}
@@ -211,8 +274,6 @@ describe("LessonOverlay", () => {
unmount();
- // Test Back button
- // Override mock to make Back button active (lessonNum > 0)
(useLessonManager as jest.Mock).mockReturnValue({
...mockUseLessonManager,
lessonNum: 1,
@@ -234,30 +295,35 @@ describe("LessonOverlay", () => {
expect(mockUseLessonManager.prevLesson).toHaveBeenCalled();
});
- test("handles lesson completion (success)", async () => {
+ test("handles lesson completion with goal evaluation", async () => {
+ (useLessonManager as jest.Mock).mockReturnValue({
+ ...mockUseLessonManager,
+ lessonData: {
+ ...mockUseLessonManager.lessonData,
+ goal: { type: "CHECKMATE" },
+ },
+ });
+
+ goalEvaluator.evaluateGoal.mockReturnValue(true);
+
render(
);
- // Wait for initialization
await waitFor(() => screen.getByText(/Test Lesson Name/));
- // Trigger onBoardStateChange with the end FEN
- act(() => {
- if (socketCallbacks.onBoardStateChange) {
- socketCallbacks.onBoardStateChange(
- mockUseLessonManager.lessonData.endFen,
- "white"
- );
- }
+ const moveBtn = await screen.findByTestId("simulate-move");
+
+ await act(async () => {
+ fireEvent.click(moveBtn);
});
- // Check if success popup appears
- expect(await screen.findByText(/Lesson completed/i)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText(/Lesson completed/i)).toBeInTheDocument();
+ });
- // Click OK
const okButton = screen.getByText("OK");
fireEvent.click(okButton);
@@ -274,12 +340,18 @@ describe("LessonOverlay", () => {
const resetButton = await screen.findByTestId("reset-button");
fireEvent.click(resetButton);
- expect(mockSocket.setGameState).toHaveBeenCalledWith(
- mockUseLessonManager.lessonData.startFen
- );
+ expect(mockUseChessGameLogic.resetLesson).toHaveBeenCalled();
});
- test("handles drag and drop move via onMove", async () => {
+ test("handles drag and drop move via onMove in free-play mode", async () => {
+ (useLessonManager as jest.Mock).mockReturnValue({
+ ...mockUseLessonManager,
+ lessonData: {
+ ...mockUseLessonManager.lessonData,
+ goal: { type: "PROMOTION", min: 1 },
+ },
+ });
+
render(
@@ -287,26 +359,43 @@ describe("LessonOverlay", () => {
);
const moveBtn = await screen.findByTestId("simulate-move");
- fireEvent.click(moveBtn);
+
+ await act(async () => {
+ fireEvent.click(moveBtn);
+ });
expect(mockUseChessGameLogic.processMove).toHaveBeenCalled();
- expect(mockSocket.sendMove).toHaveBeenCalledWith({ from: "e2", to: "e4" });
- expect(mockSocket.sendLastMove).toHaveBeenCalledWith("e2", "e4");
+ expect(eventLogger.createMoveEvent).toHaveBeenCalled();
});
- test("handles pawn promotion via onPromotion", async () => {
+ test("renders correctly with promotion goal", async () => {
+ (useLessonManager as jest.Mock).mockReturnValue({
+ ...mockUseLessonManager,
+ lessonData: {
+ ...mockUseLessonManager.lessonData,
+ startFen: "4k3/4P3/8/8/8/8/8/4K3 w - - 0 1", // Simple position with white pawn on e7 ready to promote
+ goal: { type: "PROMOTION", min: 1 },
+ },
+ });
+
render(
);
- const promoBtn = await screen.findByTestId("simulate-promotion");
- fireEvent.click(promoBtn);
+ // Wait for the board to be ready
+ await waitFor(() => {
+ const boardDisabled = screen.getByTestId("board-disabled");
+ expect(boardDisabled).toHaveTextContent("false");
+ });
- const lastCallArg = mockSocket.sendMove.mock.calls.slice(-1)[0][0];
- expect(lastCallArg.promotion).toBe("q");
- expect(mockUseChessGameLogic.processMove).toHaveBeenCalled();
+ // Verify promotion button exists (meaning the ChessBoard mock is rendering)
+ const promoBtn = screen.getByTestId("simulate-promotion");
+ expect(promoBtn).toBeInTheDocument();
+
+ // Verify goal mode indicator (Undo button instead of Reset)
+ expect(screen.getByText("Undo")).toBeInTheDocument();
});
test("does not initialize when socket is disconnected and board is disabled", async () => {
@@ -317,34 +406,10 @@ describe("LessonOverlay", () => {
);
- await waitFor(() => {
- expect(mockSocket.setGameStateWithColor).not.toHaveBeenCalled();
- });
-
const disabledIndicator = await screen.findByTestId("board-disabled");
expect(disabledIndicator.textContent).toBe("true");
});
- test("initializes after reconnecting to socket", async () => {
- mockSocket.connected = false;
- const { rerender } = render(
-
-
-
- );
-
- mockSocket.connected = true;
- rerender(
-
-
-
- );
-
- await waitFor(() => {
- expect(mockSocket.setGameStateWithColor).toHaveBeenCalled();
- });
- });
-
test("shows error popup on socket error", async () => {
render(
@@ -361,112 +426,57 @@ describe("LessonOverlay", () => {
).toBeInTheDocument();
});
- test("lesson completion via promotion lesson type shows success popup", async () => {
- (useLessonManager as jest.Mock).mockReturnValue({
- ...mockUseLessonManager,
- lessonData: {
- ...mockUseLessonManager.lessonData,
- info: "promote your pawn",
- },
- });
-
- render(
-
-
-
- );
-
- act(() => {
- socketCallbacks.onBoardStateChange &&
- socketCallbacks.onBoardStateChange(
- "rnbqkbnr/pppppppp/8/8/4Q3/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
- "white"
- );
- });
-
- expect(await screen.findByText(/Lesson completed/i)).toBeInTheDocument();
- });
-
- test("lesson completion via checkmate lesson type shows success popup", async () => {
+ test("puzzle mode with solution uses exact move sequence", async () => {
(useLessonManager as jest.Mock).mockReturnValue({
...mockUseLessonManager,
lessonData: {
...mockUseLessonManager.lessonData,
- info: "checkmate the opponent",
+ startFen: "r3k2r/ppp2pp1/2np4/2B1p2n/2B1P1Nq/3P4/PPP2PP1/RN1Q1RK1 b kq - 0 11",
+ solution: "Qf2+ Kh1 Qg1+ Rxg1 Nf2#",
+ info: "Checkmate in 3 moves",
+ goal: null,
},
});
- const { Chess } = require("chess.js");
- const game = new Chess();
- game.move("f3");
- game.move("e5");
- game.move("g4");
- game.move("Qh4#");
- const mateFen = game.fen();
-
- render(
+ const { container } = render(
);
- act(() => {
- socketCallbacks.onBoardStateChange &&
- socketCallbacks.onBoardStateChange(mateFen, "white");
- });
-
- expect(await screen.findByText(/Lesson completed/i)).toBeInTheDocument();
- });
-
- test("lesson completion via draw lesson type shows success popup", async () => {
- (useLessonManager as jest.Mock).mockReturnValue({
- ...mockUseLessonManager,
- lessonData: {
- ...mockUseLessonManager.lessonData,
- info: "hold the draw",
- },
- });
-
- render(
-
-
-
- );
-
- act(() => {
- socketCallbacks.onBoardStateChange &&
- socketCallbacks.onBoardStateChange(
- "8/8/8/8/8/8/7k/7K w - - 0 1",
- "white"
- );
+ // In puzzle mode, the undo button should say "Reset" instead of "Undo"
+ await waitFor(() => {
+ expect(screen.getByText("Reset")).toBeInTheDocument();
});
-
- expect(await screen.findByText(/Lesson completed/i)).toBeInTheDocument();
+
+ // Verify the lesson info displays the puzzle instruction in the lesson description area
+ const lessonDescription = container.querySelector(".lessonDescription");
+ expect(lessonDescription).toHaveTextContent("Checkmate in 3 moves");
});
- test("handles invalid FEN strings in position lesson without crashing", async () => {
- const badFen = "invalid-fen-string";
+ test("goal mode shows goal mode indicator", async () => {
(useLessonManager as jest.Mock).mockReturnValue({
...mockUseLessonManager,
lessonData: {
...mockUseLessonManager.lessonData,
- info: "get a winning position",
- endFen: badFen,
+ goal: { type: "PROMOTION", min: 1 },
+ solution: null,
},
});
- render(
+ const { container } = render(
);
- act(() => {
- socketCallbacks.onBoardStateChange &&
- socketCallbacks.onBoardStateChange(badFen, "white");
+ // In goal mode (free-play), the undo button should say "Undo" instead of "Reset"
+ await waitFor(() => {
+ expect(screen.getByText("Undo")).toBeInTheDocument();
});
-
- expect(await screen.findByText(/Lesson completed/i)).toBeInTheDocument();
- expect(screen.getByTestId("chess-board")).toBeInTheDocument();
+
+ // Verify the lesson info displays in the lesson description area
+ const lessonDescription = container.querySelector(".lessonDescription");
+ expect(lessonDescription).toHaveTextContent("get a winning position");
});
-});
+});
\ No newline at end of file
diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx
index 1ecd271e..aafde496 100644
--- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx
+++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx
@@ -1,25 +1,33 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { useNavigate, useLocation } from 'react-router';
import { useCookies } from 'react-cookie';
import { Chess } from 'chess.js';
-import pageStyles from './Lesson-overlay.module.scss';
-import profileStyles from './Lesson-overlay-profile.module.scss';
+import { io } from 'socket.io-client';
+import ChessBoard, { ChessBoardRef } from '../../../../components/ChessBoard/ChessBoard';
+import PromotionPopup from '../../lessons-main/PromotionPopup';
import MoveTracker from '../move-tracker/MoveTracker';
import { environment } from "../../../../environments/environment";
-import ChessBoard, { ChessBoardRef } from '../../../../components/ChessBoard/ChessBoard';
+
import { Move } from "../../../../core/types/chess";
+import { EvaluationContext, Goal } from '../../../../core/types/goals';
+import { evaluateGoal } from '../../../../core/utils/goalEvaluator';
+import { EventLog, createMoveEvent } from '../../../../core/utils/eventLogger';
+import { getConstrainedMove } from '../../../../core/utils/opponentConstraints';
+
+import { useChessGameLogic } from './hooks/useChessGameLogic';
+import { useLessonManager } from './hooks/useLessonManager';
+import { useChessSocket } from './hooks/useChessSocket';
+import { useTimeTracking } from './hooks/useTimeTracking';
+
import { ReactComponent as RedoIcon } from '../../../../assets/images/icons/icon_redo.svg';
import { ReactComponent as BackIcon } from '../../../../assets/images/icons/icon_back.svg';
import { ReactComponent as BackIconInactive } from '../../../../assets/images/icons/icon_back_inactive.svg';
import { ReactComponent as NextIcon } from '../../../../assets/images/icons/icon_next.svg';
import { ReactComponent as NextIconInactive } from '../../../../assets/images/icons/icon_next_inactive.svg';
-import { useNavigate, useLocation } from 'react-router';
-import PromotionPopup from '../../lessons-main/PromotionPopup';
-// Custom Hooks
-import { useChessGameLogic } from './hooks/useChessGameLogic';
-import { useLessonManager } from './hooks/useLessonManager';
-import { useChessSocket } from './hooks/useChessSocket';
-import { useTimeTracking } from './hooks/useTimeTracking';
+import pageStyles from './Lesson-overlay.module.scss';
+import profileStyles from './Lesson-overlay-profile.module.scss';
+
type LessonOverlayProps = {
propPieceName?: any;
@@ -30,6 +38,46 @@ type LessonOverlayProps = {
onChessReset?: (fen: string) => void;
};
+interface SolutionMove {
+ san: string;
+ isPlayerMove: boolean;
+}
+
+function parsePGNSolution(pgn: string, fenTurn: 'white' | 'black', playerColor: 'white' | 'black'): SolutionMove[] {
+ if (!pgn) return [];
+
+ // Remove move numbers and clean up
+ const cleanPgn = pgn.replace(/\d+\./g, '').trim();
+ const moves = cleanPgn.split(/\s+/).filter(m => m.length > 0);
+ const firstMoveIsPlayer = fenTurn === playerColor;
+
+ return moves.map((san, index) => ({
+ san,
+ isPlayerMove: index % 2 === 0 ? firstMoveIsPlayer : !firstMoveIsPlayer
+ }));
+}
+
+function sanToMove(san: string, game: Chess): Move | null {
+ try {
+ const move = game.move(san);
+ if (!move) return null;
+
+ game.undo(); // Undo to keep game state unchanged
+
+ return {
+ from: move.from,
+ to: move.to,
+ promotion: move.promotion
+ };
+ } catch (e) {
+ return null;
+ }
+}
+
+function movesMatch(move1: Move, move2: Move): boolean {
+ return move1.from === move2.from && move1.to === move2.to && (move1.promotion || '') === (move2.promotion || '');
+}
+
const LessonOverlay: React.FC = ({
propPieceName = null,
propLessonNumber = null,
@@ -59,6 +107,11 @@ const LessonOverlay: React.FC = ({
const [moveHistory, setMoveHistory] = useState([]);
const [highlightSquares, setHighlightSquares] = useState([]);
+ // Puzzle/Solution tracking
+ const [solutionMoves, setSolutionMoves] = useState([]);
+ const [currentSolutionIndex, setCurrentSolutionIndex] = useState(0);
+ const [isPuzzleMode, setIsPuzzleMode] = useState(false);
+
// Popups
const [showVPopup, setShowVPopup] = useState(false);
const [showXPopup, setShowXPopup] = useState(false);
@@ -76,11 +129,19 @@ const LessonOverlay: React.FC = ({
// Refs for lesson data
const lessonStartFENRef = useRef("");
- const lessonEndFENRef = useRef("");
- const lessonTypeRef = useRef("default");
+ const playerColorRef = useRef<'white' | 'black'>('white');
const isInitializedRef = useRef(false);
+ const gameRef = useRef(new Chess());
- // Initialize socket with all callbacks
+ // Stockfish socket for free-play mode
+ const stockfishSocketRef = useRef(null);
+ const [stockfishConnected, setStockfishConnected] = useState(false);
+ const [stockfishSessionStarted, setStockfishSessionStarted] = useState(false);
+
+ const eventLogRef = useRef(new EventLog());
+ const [lessonGoal, setLessonGoal] = useState(null);
+
+ // Initialize socket
const socket = useChessSocket({
student: styleType === 'profile' ? cookies.login?.studentId : "guest_student",
mentor: "mentor_" + piece,
@@ -88,22 +149,17 @@ const LessonOverlay: React.FC = ({
serverUrl: environment.urls.chessServerURL,
mode: 'lesson',
- // Board state changes (PRIMARY SOURCE OF TRUTH)
onBoardStateChange: (newFEN, color) => {
- setCurrentFEN(newFEN);
-
- if (color) {
- setBoardOrientation(color);
+ try {
+ gameRef.current.load(newFEN);
+ setCurrentFEN(newFEN);
+ if (color) setBoardOrientation(color);
+ if (onChessMove) onChessMove(newFEN);
+ } catch (e) {
+ console.error("Invalid FEN received", e);
}
-
- // Notify parent if callback provided
- if (onChessMove) onChessMove(newFEN);
-
- // Check lesson completion
- checkLessonCompletion(newFEN);
},
- // Move highlighting
onLastMove: (from, to) => {
setHighlightSquares([from, to]);
if (chessBoardRef.current) {
@@ -111,7 +167,6 @@ const LessonOverlay: React.FC = ({
}
},
- // Color assignment
onColorAssigned: (color) => {
setBoardOrientation(color);
if (chessBoardRef.current) {
@@ -119,12 +174,10 @@ const LessonOverlay: React.FC = ({
}
},
- // Reset handler
onReset: () => {
handleReset();
},
- // Error handler
onError: (msg) => {
console.error("Socket error:", msg);
setShowError(true);
@@ -152,6 +205,151 @@ const LessonOverlay: React.FC = ({
useTimeTracking(piece, cookies);
+ // Initialize Stockfish socket for free-play mode
+ useEffect(() => {
+ const stockfishSocket = io(environment.urls.stockfishServerURL, {
+ transports: ['websocket'],
+ reconnection: true,
+ });
+
+ stockfishSocketRef.current = stockfishSocket;
+
+ stockfishSocket.on('connect', () => {
+ setStockfishConnected(true);
+
+ // Start session immediately after connection for free-play lessons
+ if (!isPuzzleMode && lessonGoal && lessonStartFENRef.current) {
+ console.log('[Stockfish] Starting session immediately after connect');
+ stockfishSocket.emit('start-session', {
+ sessionType: 'player-vs-computer',
+ fen: lessonStartFENRef.current,
+ });
+ }
+ });
+
+ stockfishSocket.on('disconnect', () => {
+ setStockfishConnected(false);
+ setStockfishSessionStarted(false);
+ });
+
+ stockfishSocket.on('session-started', ({ success }: any) => {
+ console.log('[Stockfish] Session started event received:', success);
+ setStockfishSessionStarted(true);
+ });
+
+ stockfishSocket.on('session-error', ({ error }: any) => {
+ console.error('[Stockfish] Session error event received:', error);
+ setStockfishSessionStarted(false);
+ });
+
+ stockfishSocket.on('evaluation-complete', ({ mode, move }: any) => {
+ if (mode === 'move' && move) {
+ handleStockfishMove(move);
+ }
+ });
+
+ stockfishSocket.on('evaluation-error', ({ error }: any) => {
+ console.error('Stockfish evaluation error:', error);
+ });
+
+ return () => {
+ if (stockfishSocket.connected) {
+ stockfishSocket.emit('end-session');
+ }
+ stockfishSocket.disconnect();
+ };
+ }, []);
+
+
+ const handleStockfishMove = useCallback((move: string) => {
+ try {
+ const moveResult = gameRef.current.move(move);
+
+ if (moveResult) {
+ const newFen = gameRef.current.fen();
+
+ // LOG OPPONENT MOVE EVENT
+ const event = createMoveEvent(moveResult, newFen, false); // false = opponent move
+ eventLogRef.current.addMove(event);
+
+ setCurrentFEN(newFen);
+ setHighlightSquares([moveResult.from, moveResult.to]);
+
+ if (chessBoardRef.current) {
+ chessBoardRef.current.highlightMove(moveResult.from, moveResult.to);
+ }
+
+ if (onChessMove) onChessMove(newFen);
+
+ // Check if student lost after computer's move
+ if (gameRef.current.isCheckmate() || gameRef.current.isStalemate()) {
+ const turn = gameRef.current.turn();
+ const playerLost = (playerColorRef.current === 'white' && turn === 'w') ||
+ (playerColorRef.current === 'black' && turn === 'b');
+
+ if (playerLost) {
+ setShowXPopup(true);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error applying Stockfish move:', error);
+ }
+ }, [onChessMove]);
+
+
+ // Fallback: Get random legal move
+ const getRandomLegalMove = useCallback((fen: string) => {
+ const tempGame = new Chess(fen);
+ const moves = tempGame.moves({ verbose: true });
+
+ if (moves.length === 0) return null;
+
+ const randomMove = moves[Math.floor(Math.random() * moves.length)];
+ return {
+ from: randomMove.from,
+ to: randomMove.to,
+ promotion: randomMove.promotion
+ };
+ }, []);
+
+
+ const requestStockfishMove = useCallback((fen: string, depth: number = 5) => {
+ console.log('[Stockfish] requestStockfishMove called');
+
+ // 1. CONSTRAINT-BASED (for free-play lessons with constraints)
+ if (lessonData?.opponentConstraints && lessonData.opponentConstraints.length > 0) {
+ console.log('[Opponent] Using constraint-based move');
+ const constrainedMove = getConstrainedMove(fen, lessonData.opponentConstraints);
+ if (constrainedMove) {
+ setTimeout(() => {
+ handleStockfishMove(`${constrainedMove.from}${constrainedMove.to}${constrainedMove.promotion || ''}`);
+ }, 300);
+ }
+ return;
+ }
+
+ // 2. STOCKFISH (for tactical lessons or advanced goals)
+ if (stockfishSocketRef.current && stockfishConnected && stockfishSessionStarted) {
+ console.log('[Opponent] Using Stockfish engine at depth:', depth);
+ stockfishSocketRef.current.emit('evaluate-fen', {
+ fen,
+ move: '',
+ level: depth,
+ });
+ return;
+ }
+
+ // 3. FALLBACK (if Stockfish not ready)
+ console.warn('[Opponent] Stockfish not ready, using random move');
+ const randomMove = getRandomLegalMove(fen);
+ if (randomMove) {
+ setTimeout(() => {
+ handleStockfishMove(`${randomMove.from}${randomMove.to}${randomMove.promotion || ''}`);
+ }, 300);
+ }
+ }, [lessonData, stockfishConnected, stockfishSessionStarted, handleStockfishMove, getRandomLegalMove]);
+
// Update piece from props
useEffect(() => {
if (propPieceName) setPiece(propPieceName);
@@ -165,11 +363,10 @@ const LessonOverlay: React.FC = ({
});
}, [piece, initialLessonNum, refreshProgress]);
+ // Main lesson initialization
useEffect(() => {
if (!lessonData?.startFen) return;
- if (!socket.connected) {
- return;
- }
+ if (!socket.connected) return;
setHidePieces(false);
setShowLPopup(false);
@@ -183,39 +380,55 @@ const LessonOverlay: React.FC = ({
// Update lesson refs
lessonStartFENRef.current = lessonData.startFen;
- lessonEndFENRef.current = lessonData.endFen;
-
- // Set initial position locally
- setCurrentFEN(lessonData.startFen);
- // Determine turn from FEN
+ // Determine player color from FEN
const turn = getTurnFromFEN(lessonData.startFen);
- const color = turn === 'white' ? 'white' : 'black';
- setBoardOrientation(color);
+ setBoardOrientation(turn);
+
+ // Initialize game position
+ gameRef.current = new Chess(lessonData.startFen);
+ setCurrentFEN(lessonData.startFen);
// Update lesson info
setInfo(lessonData.info || "");
setName(lessonData.name || "");
- // Determine lesson type
- const infoLower = (lessonData.info || "").toLowerCase();
- const nameLower = (lessonData.name || "").toLowerCase();
-
- if (infoLower.includes("checkmate the opponent") || nameLower.includes("= win")) {
- lessonTypeRef.current = "checkmate";
- } else if (infoLower.includes("get a winning position")) {
- lessonTypeRef.current = "position";
- } else if (infoLower.includes("equalize in")) {
- lessonTypeRef.current = "equalize";
- } else if (infoLower.includes("promote your pawn")) {
- lessonTypeRef.current = "promote";
- } else if (infoLower.includes("hold the draw") || nameLower.includes("draw")) {
- lessonTypeRef.current = "draw";
+ // Determine lesson mode: puzzle (has solution) or free-play (has goal)
+ const hasSolution = lessonData.solution && lessonData.solution.trim().length > 0;
+ const hasGoal = lessonData.goal != null;
+
+ if (hasSolution) {
+ // PUZZLE MODE: Exact move sequence required
+ setIsPuzzleMode(true);
+ setLessonGoal(null);
+
+ const parsedMoves = parsePGNSolution(lessonData.solution, turn, playerColorRef.current);
+ setSolutionMoves(parsedMoves);
+ setCurrentSolutionIndex(0);
+
+ console.log('[Lesson Mode] Puzzle mode - solution:', lessonData.solution);
+ } else if (hasGoal) {
+ // FREE-PLAY MODE: Goal-based validation
+ setIsPuzzleMode(false);
+ setLessonGoal(lessonData.goal);
+ setSolutionMoves([]);
+ setCurrentSolutionIndex(0);
+
+ console.log('[Lesson Mode] Free-play mode - goal:', lessonData.goal);
} else {
- lessonTypeRef.current = "default";
+ // LEGACY MODE: Fall back to text parsing (old lessons)
+ setIsPuzzleMode(false);
+ setLessonGoal(null);
+ setSolutionMoves([]);
+ setCurrentSolutionIndex(0);
+
+ console.log('[Lesson Mode] Legacy mode - using text parsing');
}
- // Initialize game on server with delay to ensure socket is ready
+ // Clear event log for new lesson
+ eventLogRef.current.clear();
+
+ // Initialize game on server
isInitializedRef.current = false;
const initTimer = setTimeout(() => {
initializeLessonOnServer();
@@ -250,143 +463,362 @@ const LessonOverlay: React.FC = ({
const initializeLessonOnServer = useCallback(() => {
if (!lessonData || isInitializedRef.current) return;
- if (!socket.connected) {
- return;
- }
+ if (!socket.connected) return;
isInitializedRef.current = true;
- // Determine player color from FEN
- const turn = getTurnFromFEN(lessonData.startFen);
- const playerColor = turn === 'white' ? 'white' : 'black';
-
- // Send lesson state to server
socket.setGameStateWithColor(
lessonData.startFen,
- playerColor,
+ playerColorRef.current,
lessonData.info
);
}, [lessonData, socket]);
- const checkLessonCompletion = useCallback((fen: string) => {
- if (!lessonEndFENRef.current) return;
-
- const lessonType = lessonTypeRef.current;
-
- // Exact FEN match for position-based lessons
- if (lessonType === "position" || lessonType === "equalize") {
- if (fen === lessonEndFENRef.current) {
- setShowVPopup(true);
- return;
- }
+ function getTurnFromFEN(fen: string): 'white' | 'black' {
+ if (!fen || typeof fen !== 'string') {
+ return 'white';
}
+ const parts = fen.split(' ');
+ return parts[1] === 'w' ? 'white' : 'black';
+ }
- // For other types, check game state
+ // Check lesson completion for free-play mode
+ const checkFreePlayCompletion = useCallback((fen: string) => {
const game = new Chess(fen);
+ const infoLower = info.toLowerCase();
+
+ // Checkmate goal
+ if (infoLower.includes('checkmate')) {
+ if (game.isCheckmate()) {
+ // Verify correct side is checkmated
+ const turn = game.turn(); // Current turn (the checkmated player)
+ const playerWon = (playerColorRef.current === 'white' && turn === 'b') || (playerColorRef.current === 'black' && turn === 'w');
+
+ if (playerWon) {
+ setShowVPopup(true);
+ return true;
+ }
+ }
+ }
- if (lessonType === "checkmate" && game.isCheckmate()) {
- setShowVPopup(true);
- } else if (lessonType === "draw" && game.isDraw()) {
- setShowVPopup(true);
- } else if (lessonType === "promote") {
- // Check if a new queen was added (pawn promoted)
+ // Promotion goal
+ if (infoLower.includes('promote')) {
const startQueens = (lessonStartFENRef.current.match(/[Qq]/g) || []).length;
const currentQueens = (fen.match(/[Qq]/g) || []).length;
if (currentQueens > startQueens) {
setShowVPopup(true);
+ return true;
}
}
- }, []);
+ // Winning position goal
+ if (infoLower.includes('winning position')) {
+ const materialDiff = calculateMaterialDifference(fen);
- function getTurnFromFEN(fen: string): 'white' | 'black' {
- if (!fen || typeof fen !== 'string') {
- return 'white';
+ if ((playerColorRef.current === 'white' && materialDiff >= 5) ||
+ (playerColorRef.current === 'black' && materialDiff <= -5)) {
+ setShowVPopup(true);
+ return true;
+ }
}
- const parts = fen.split(' ');
- return parts[1] === 'w' ? 'white' : 'black';
- }
- const handleMove = useCallback((move: Move) => {
+ return false;
+ }, [info]);
+
+ const calculateMaterialDifference = (fen: string): number => {
+ const pieceValues: { [key: string]: number } = {
+ 'p': 1, 'n': 3, 'b': 3, 'r': 5, 'q': 9,
+ 'P': -1, 'N': -3, 'B': -3, 'R': -5, 'Q': -9
+ };
+
+ const position = fen.split(' ')[0];
+ let material = 0;
+
+ for (const char of position) {
+ if (pieceValues[char]) {
+ material += pieceValues[char];
+ }
+ }
+
+ return material; // Positive = black ahead, negative = white ahead
+ };
+
+ const handlePuzzleMove = useCallback((move: Move) => {
+ if (currentSolutionIndex >= solutionMoves.length) {
+ console.error('Solution index out of bounds');
+ return;
+ }
+
+ const expectedSolutionMove = solutionMoves[currentSolutionIndex];
+
+ if (!expectedSolutionMove.isPlayerMove) {
+ console.error('Not player turn in solution');
+ return;
+ }
+
+ const tempGame = new Chess(currentFEN);
+ const expectedMove = sanToMove(expectedSolutionMove.san, tempGame);
+
+ if (!expectedMove) {
+ console.error('Could not parse solution move:', expectedSolutionMove.san);
+ setShowError(true);
+ return;
+ }
+
+ // Check if player's move matches expected move
+ if (!movesMatch(move, expectedMove)) {
+ // Wrong move!
+ setShowXPopup(true);
+ return;
+ }
+
+ // Correct move! Apply it
try {
- // Process move locally
+ gameRef.current.move(move);
+ const newFen = gameRef.current.fen();
+ setCurrentFEN(newFen);
+
processMove();
+ setMoveHistory(prev => [...prev, `${move.from}-${move.to}`]);
+ setHighlightSquares([move.from, move.to]);
+
+ if (onChessMove) onChessMove(newFen);
+
+ // Move to next solution move
+ const nextIndex = currentSolutionIndex + 1;
- // Add to history
+ // Check if this was the last move
+ if (nextIndex >= solutionMoves.length) {
+ // Puzzle complete!
+ setShowVPopup(true);
+ return;
+ }
+
+ setCurrentSolutionIndex(nextIndex);
+
+ // Auto-play opponent's response if there is one
+ const nextSolutionMove = solutionMoves[nextIndex];
+ if (nextSolutionMove && !nextSolutionMove.isPlayerMove) {
+ setTimeout(() => {
+ playOpponentSolutionMove(nextIndex);
+ }, 500);
+ }
+
+ } catch (error) {
+ console.error("Error applying puzzle move:", error);
+ setShowError(true);
+ }
+ }, [currentSolutionIndex, solutionMoves, currentFEN, processMove, onChessMove]);
+
+ // Play opponent's predetermined move from solution
+ const playOpponentSolutionMove = useCallback((index: number) => {
+ if (index >= solutionMoves.length) return;
+
+ const opponentSolutionMove = solutionMoves[index];
+ if (opponentSolutionMove.isPlayerMove) {
+ console.error('Expected opponent move but got player move');
+ return;
+ }
+
+ try {
+ const move = gameRef.current.move(opponentSolutionMove.san);
+ if (!move) {
+ console.error('Could not play opponent move:', opponentSolutionMove.san);
+ return;
+ }
+
+ const newFen = gameRef.current.fen();
+ setCurrentFEN(newFen);
+ setHighlightSquares([move.from, move.to]);
+
+ if (chessBoardRef.current) {
+ chessBoardRef.current.highlightMove(move.from, move.to);
+ }
+
+ if (onChessMove) onChessMove(newFen);
+
+ // Move to next index
+ setCurrentSolutionIndex(index + 1);
+
+ // Check if game ended
+ if (gameRef.current.isCheckmate() || gameRef.current.isStalemate()) {
+ const turn = gameRef.current.turn();
+ const playerLost = (playerColorRef.current === 'white' && turn === 'w') ||
+ (playerColorRef.current === 'black' && turn === 'b');
+
+ if (playerLost) {
+ setShowXPopup(true);
+ }
+ }
+
+ } catch (error) {
+ console.error("Error playing opponent move:", error);
+ setShowError(true);
+ }
+ }, [solutionMoves, onChessMove]);
+
+ // Handle move in free-play mode (with Stockfish opponent)
+ const handleFreePlayMove = useCallback(async (move: Move) => {
+ try {
+ // Apply student move
+ const moveResult = gameRef.current.move(move);
+ if (!moveResult) {
+ console.error('Invalid move');
+ return;
+ }
+
+ const afterFen = gameRef.current.fen();
+
+ if (stockfishSocketRef.current && stockfishSessionStarted) {
+ stockfishSocketRef.current.emit('update-fen', { fen: afterFen });
+ }
+
+ const event = createMoveEvent(moveResult, afterFen, true); // true = player move
+ eventLogRef.current.addMove(event);
+
+ setCurrentFEN(afterFen);
+ processMove();
setMoveHistory(prev => [...prev, `${move.from}-${move.to}`]);
+ setHighlightSquares([move.from, move.to]);
+
+ if (onChessMove) onChessMove(afterFen);
+
+ if (lessonGoal) {
+ const context: EvaluationContext = {
+ events: eventLogRef.current.getEvents(),
+ currentGame: gameRef.current,
+ startFen: lessonStartFENRef.current,
+ currentFen: afterFen,
+ playerColor: playerColorRef.current,
+ moveCount: eventLogRef.current.getEvents().length
+ };
+
+ const goalAchieved = evaluateGoal(lessonGoal, context);
+
+ if (goalAchieved) {
+ console.log('🎉 Goal achieved!');
+ setShowVPopup(true);
+ return;
+ }
+ } else {
+ if (checkFreePlayCompletion(afterFen)) {
+ return;
+ }
+ }
- // Send to server
- socket.sendMove(move);
- socket.sendLastMove(move.from, move.to);
+ // Check if game ended in student's favor
+ if (gameRef.current.isCheckmate() || gameRef.current.isStalemate()) {
+ const turn = gameRef.current.turn();
+ const playerWon = (playerColorRef.current === 'white' && turn === 'b') || (playerColorRef.current === 'black' && turn === 'w');
+
+ if (playerWon) {
+ setShowVPopup(true);
+ } else {
+ setShowXPopup(true);
+ }
+ return;
+ }
+
+ // Request opponent move from Stockfish
+ setTimeout(() => {
+ const currentFen = gameRef.current.fen();
+ requestStockfishMove(currentFen, 5);
+ }, 500);
} catch (error) {
- console.error("Error handling move:", error);
+ console.error("Error handling free play move:", error);
setShowError(true);
}
- }, [socket, processMove]);
+ }, [lessonGoal, processMove, onChessMove, checkFreePlayCompletion, requestStockfishMove, stockfishSessionStarted]);
+
+ // Main move handler - routes to puzzle or free-play
+ const handleMove = useCallback((move: Move) => {
+ if (isPuzzleMode) {
+ handlePuzzleMove(move);
+ } else {
+ handleFreePlayMove(move);
+ }
+ }, [isPuzzleMode, handlePuzzleMove, handleFreePlayMove]);
const handleInvalidMove = useCallback(() => {
- // Show a brief error message instead of breaking
- const errorTimeout = setTimeout(() => {
- // Could add a toast notification here
- }, 2000);
- return () => clearTimeout(errorTimeout);
+ // Could show a toast notification here
+ console.log("Invalid move attempted");
}, []);
const undoMove = useCallback(() => {
if (!chessBoardRef.current) return;
if (moveHistory.length === 0) return;
- // Undo locally
- chessBoardRef.current.undo();
+ // For puzzle mode, undoing is tricky - might want to disable or reset
+ if (isPuzzleMode) {
+ // Reset to beginning of puzzle
+ handleReset();
+ return;
+ }
- // Update history
+ // For free-play, undo last move
+ gameRef.current.undo();
+ const newFen = gameRef.current.fen();
+ setCurrentFEN(newFen);
setMoveHistory(prev => prev.slice(0, -1));
- // Send to server
- socket.undo();
-
- }, [socket, moveHistory.length]);
-
- const handleReset = useCallback(() => {
-
if (chessBoardRef.current) {
chessBoardRef.current.reset();
}
- // Reset to lesson start position
- const startFen = lessonStartFENRef.current;
- setCurrentFEN(startFen);
+ }, [moveHistory.length, isPuzzleMode]);
+
+ const handleReset = useCallback(() => {
+ gameRef.current = new Chess(lessonStartFENRef.current);
+ setCurrentFEN(lessonStartFENRef.current);
setMoveHistory([]);
setHighlightSquares([]);
+ setCurrentSolutionIndex(0);
- // Reset on server
- socket.setGameState(startFen);
+ eventLogRef.current.clear();
- // Notify parent
- if (onChessReset) onChessReset(startFen);
+ if (chessBoardRef.current) {
+ chessBoardRef.current.setPosition(lessonStartFENRef.current);
+ chessBoardRef.current.clearHighlights();
+ }
- // Reset game logic
- resetLesson(startFen);
+ if (onChessReset) onChessReset(lessonStartFENRef.current);
+ resetLesson(lessonStartFENRef.current);
- }, [socket, onChessReset, resetLesson]);
+ }, [onChessReset, resetLesson]);
const previousLesson = async () => {
+
+ if (stockfishSocketRef.current && stockfishSessionStarted) {
+ stockfishSocketRef.current.emit('end-session');
+ setStockfishSessionStarted(false);
+ }
+
isInitializedRef.current = false;
await managerPrevLesson();
resetLesson(null);
setMoveHistory([]);
setHighlightSquares([]);
+ setCurrentSolutionIndex(0);
+ eventLogRef.current.clear();
};
const nextLesson = async () => {
+
+ if (stockfishSocketRef.current && stockfishSessionStarted) {
+ stockfishSocketRef.current.emit('end-session');
+ setStockfishSessionStarted(false);
+ }
+
isInitializedRef.current = false;
await managerNextLesson();
resetLesson(null);
setMoveHistory([]);
setHighlightSquares([]);
+ setCurrentSolutionIndex(0);
+ eventLogRef.current.clear();
};
const handleVPopup = async () => {
@@ -396,8 +828,7 @@ const LessonOverlay: React.FC = ({
await updateCompletion();
// Reset for next attempt
- resetLesson(lessonStartFENRef.current);
- setMoveHistory([]);
+ handleReset();
};
const handleXPopup = () => {
@@ -418,8 +849,7 @@ const LessonOverlay: React.FC = ({
promotion: piece.toLowerCase()
};
- socket.sendMove(move);
- processMove();
+ handleMove(move);
};
return (
@@ -437,7 +867,7 @@ const LessonOverlay: React.FC = ({
onClick={undoMove}
disabled={moveHistory.length === 0}
>
- Undo
+ {isPuzzleMode ? 'Reset' : 'Undo'}
- Lesson failed
- Please try again
+ Wrong move!
+ Please try again.
diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts
index 48125bc1..38fe1f51 100644
--- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts
+++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useChessSocket.ts
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { io, Socket } from "socket.io-client";
-import { Chess } from "chess.js";
import { Move, BoardState, MousePosition, GameConfig, GameMode, PlayerColor } from "../../../../../core/types/chess";
interface UseChessSocketOptions {
@@ -60,20 +59,6 @@ const normalizeFen = (fen: string): string => {
return paddedParts.join(" ");
};
-// ======== SAFE CHESS INSTANCE CREATION ========
-const createSafeChessInstance = (fen?: string): Chess => {
- try {
- if (fen) {
- const normalizedFen = normalizeFen(fen);
- return new Chess(normalizedFen);
- }
- return new Chess();
- } catch (err) {
- console.error("Failed to create Chess instance with FEN:", fen, err);
- return new Chess(); // Return default starting position
- }
-};
-
export const useChessSocket = ({
student,
mentor = "",
diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonLogger.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonLogger.ts
new file mode 100644
index 00000000..9824f57b
--- /dev/null
+++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonLogger.ts
@@ -0,0 +1,71 @@
+import { useRef, useCallback } from 'react';
+import { Chess } from 'chess.js';
+import { MoveEvent } from '../../../../../core/types/goals';
+
+export function useLessonLogger(startFen: string) {
+ // Initialize with the starting FEN
+ const chessRef = useRef(new Chess(startFen));
+ const eventsRef = useRef([]);
+
+ const logMove = useCallback((moveInput: string | { from: string; to: string; promotion?: string }) => {
+ const game = chessRef.current;
+
+ try {
+ // 1. Execute the move
+ const result = game.move(moveInput);
+ if (!result) return null;
+
+ const afterFen = game.fen();
+
+ // 2. Logic for double pawn push
+ const doublePawnPush =
+ result.piece === 'p' &&
+ Math.abs(parseInt(result.from[1]) - parseInt(result.to[1])) === 2;
+
+ // 3. Build the event object
+ const event: MoveEvent = {
+ san: result.san,
+ from: result.from,
+ to: result.to,
+ piece: result.piece,
+ captured: result.captured,
+ promotion: result.promotion,
+ // Use the game state methods for accuracy
+ check: game.inCheck(),
+ checkmate: game.isCheckmate(),
+ doublePawnPush,
+ enPassant: result.flags.includes('e'),
+ castling:
+ result.flags.includes('k')
+ ? 'kingside'
+ : result.flags.includes('q')
+ ? 'queenside'
+ : undefined,
+ fen: afterFen,
+ };
+
+ eventsRef.current.push(event);
+ return { fen: afterFen, event };
+
+ } catch (err) {
+ console.error('Invalid move attempted:', moveInput, err);
+ return null;
+ }
+ }, []);
+
+ const makeMove = useCallback(
+ (san: string) => {
+ return logMove(san);
+ },
+ [logMove]
+ );
+
+ const resetLesson = useCallback(() => {
+ chessRef.current = new Chess(startFen);
+ eventsRef.current = [];
+ }, [startFen]);
+
+ const getEvents = useCallback(() => [...eventsRef.current], []);
+
+ return { makeMove, resetLesson, getEvents };
+}