Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions frontend/__tests__/cheatDetection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 22 additions & 20 deletions frontend/lib/cheatDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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));
Expand Down
Loading