diff --git a/frontend/__tests__/cheatDetection.test.ts b/frontend/__tests__/cheatDetection.test.ts new file mode 100644 index 00000000..d55a3178 --- /dev/null +++ b/frontend/__tests__/cheatDetection.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Move } from "chess.js"; +import { CheatDetectionEngine } from "@/lib/cheatDetection"; + +// Board part with 10 piece letters -> below COMPLEX_POSITION_THRESHOLD (30). +const SIMPLE_FEN = "rnbqk3/8/8/8/8/8/8/RNBQK3 w - - 0 1"; +// Full starting board -> 32 piece letters -> a "complex" position. +const COMPLEX_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + +// Minimal pawn move: piece "p" keeps countBlunders from constructing a Chess board, +// and a non-central destination keeps isHighQualityMove false. Only fenBefore piece +// count and timestamps matter for the complexity-speed heuristic. +function pawnMove(): Move { + return { color: "w", piece: "p", from: "a2", to: "a3", san: "a3", flags: "n" } as unknown as Move; +} + +describe("CheatDetectionEngine complexity-speed think-time alignment", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("attributes each complex move's own think time, not a neighbour's", () => { + const engine = new CheatDetectionEngine(); + + // Single colour throughout. Every complex position was actually played SLOWLY (5000ms); + // the one fast gap (500ms) precedes a NON-complex move. Correct alignment must therefore + // report zero fast complex moves. The previous off-by-one indexing pulled the 500ms gap + // onto a complex move and produced a non-zero complexity-speed score. + const moves: Array<{ fen: string; t: number }> = [ + { fen: SIMPLE_FEN, t: 0 }, + { fen: SIMPLE_FEN, t: 1000 }, + { fen: COMPLEX_FEN, t: 6000 }, // think 5000ms (slow) + { fen: COMPLEX_FEN, t: 11000 }, // think 5000ms (slow) + { fen: COMPLEX_FEN, t: 16000 }, // think 5000ms (slow) + { fen: SIMPLE_FEN, t: 16500 }, // think 500ms (fast, but NOT complex) + { fen: SIMPLE_FEN, t: 21500 }, + ]; + + moves.forEach((m, i) => { + vi.setSystemTime(new Date(m.t)); + engine.recordMove({ + san: "a3", + fenBefore: m.fen, + verbose: pawnMove(), + color: "w", + moveNumber: 11 + i, + }); + }); + + const result = engine.analyse("w"); + + expect(result.details.complexitySpeed).toBe(0); + }); + + it("flags genuinely fast play in complex positions", () => { + const engine = new CheatDetectionEngine(); + + // Here the complex positions themselves were all played quickly (500ms each). + const moves: Array<{ fen: string; t: number }> = [ + { fen: SIMPLE_FEN, t: 0 }, + { fen: SIMPLE_FEN, t: 5000 }, + { fen: COMPLEX_FEN, t: 5500 }, // think 500ms (fast) + { fen: COMPLEX_FEN, t: 6000 }, // think 500ms (fast) + { fen: COMPLEX_FEN, t: 6500 }, // think 500ms (fast) + { fen: COMPLEX_FEN, t: 7000 }, // think 500ms (fast) + { fen: SIMPLE_FEN, t: 12000 }, + ]; + + moves.forEach((m, i) => { + vi.setSystemTime(new Date(m.t)); + engine.recordMove({ + san: "a3", + fenBefore: m.fen, + verbose: pawnMove(), + color: "w", + moveNumber: 11 + i, + }); + }); + + const result = engine.analyse("w"); + + expect(result.details.complexitySpeed).toBeGreaterThan(0); + }); +}); diff --git a/frontend/lib/cheatDetection.ts b/frontend/lib/cheatDetection.ts index f8947882..cccfd7a5 100644 --- a/frontend/lib/cheatDetection.ts +++ b/frontend/lib/cheatDetection.ts @@ -124,7 +124,7 @@ export class CheatDetectionEngine { const thinkTimes = this.computeThinkTimes(color); const timeConsistency = this.scoreTimeConsistency(thinkTimes); const accuracyScore = this.scoreAccuracy(playerMoves); - const complexitySpeed = this.scoreComplexitySpeed(playerMoves, thinkTimes); + const complexitySpeed = this.scoreComplexitySpeed(playerMoves); const blunderAvoidance = this.scoreBlunderAvoidance(playerMoves); const blunderCount = this.countBlunders(playerMoves); @@ -315,32 +315,34 @@ export class CheatDetectionEngine { * Fast moves in complex positions = suspicious (engine evaluates instantly). * Returns 0–100 (higher = more suspicious). */ - private scoreComplexitySpeed( - playerMoves: MoveEntry[], - thinkTimes: number[], - ): number { + private scoreComplexitySpeed(playerMoves: MoveEntry[]): number { if (playerMoves.length < MIN_MOVES_FOR_ANALYSIS) return 0; - // Find moves in complex positions (many pieces on board) - const complexMoveIndices: number[] = []; - playerMoves.forEach((move, i) => { - const pieceCount = this.countPieces(move.fenBefore); - if (pieceCount >= COMPLEX_POSITION_THRESHOLD) { - complexMoveIndices.push(i); + // Think time for each complex-position move, measured against this player's *own* + // previous move. We deliberately compute the gap here rather than indexing into the + // compacted `thinkTimes` array: that array starts at move 1 and is filtered, so its + // indices are not aligned with `playerMoves` and would attribute a neighbour's think + // time to a complex move. The first move (i === 0) has no preceding own move, so its + // think time is unknown and it is excluded. + const complexThinkTimes: number[] = []; + for (let i = 1; i < playerMoves.length; i++) { + const pieceCount = this.countPieces(playerMoves[i].fenBefore); + if (pieceCount < COMPLEX_POSITION_THRESHOLD) { + continue; } - }); - - if (complexMoveIndices.length < 3) return 0; - // Check thinking times for complex positions - let fastComplexMoves = 0; - for (const idx of complexMoveIndices) { - if (idx < thinkTimes.length && thinkTimes[idx] < FAST_MOVE_THRESHOLD_MS) { - fastComplexMoves++; + const elapsed = playerMoves[i].timestamp - playerMoves[i - 1].timestamp; + if (elapsed > 0) { + complexThinkTimes.push(elapsed); } } - const fastRate = fastComplexMoves / complexMoveIndices.length; + if (complexThinkTimes.length < 3) return 0; + + const fastComplexMoves = complexThinkTimes.filter( + (elapsed) => elapsed < FAST_MOVE_THRESHOLD_MS, + ).length; + const fastRate = fastComplexMoves / complexThinkTimes.length; // High rate of fast moves in complex positions → suspicious if (fastRate >= 0.5) return Math.min(90, Math.round(fastRate * 120));