diff --git a/client/src/components/Typing.css b/client/src/components/Typing.css
index e3cd4282..cf777f38 100644
--- a/client/src/components/Typing.css
+++ b/client/src/components/Typing.css
@@ -304,6 +304,28 @@
font-size: 1rem;
}
+.anticheat-block {
+ margin: 1rem auto 0;
+ padding: 0.75rem 1rem;
+ max-width: 480px;
+ border-radius: 10px;
+ background: rgba(255, 80, 80, 0.18);
+ border: 1px solid rgba(255, 80, 80, 0.35);
+ color: #ff7b7b;
+ text-align: center;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.anticheat-block strong {
+ display: block;
+ font-weight: 700;
+ color: #ff9c9c;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
/* Caps Lock Warning styles */
/* Caps Lock warning — banner above snippet */
.caps-lock-warning {
diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx
index 6a56fd99..c97c5ffb 100644
--- a/client/src/components/Typing.jsx
+++ b/client/src/components/Typing.jsx
@@ -42,7 +42,7 @@ function Typing({
snippetType,
snippetDepartment
}) {
- const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet } = useRace();
+ const { raceState, setRaceState, typingState, setTypingState, updateProgress, handleInput: raceHandleInput, loadNewSnippet, anticheatState, flagSuspicious, markTrustedInteraction } = useRace();
const { socket } = useSocket();
const { user } = useAuth();
const [input, setInput] = useState('');
@@ -625,8 +625,46 @@ function Typing({
};
}, [raceState.inProgress, raceState.startTime, raceState.completed, typingState.correctChars]); // Include typingState.correctChars
+ const handleBeforeInputGuard = (e) => {
+ if (anticheatState.locked) {
+ e.preventDefault();
+ return;
+ }
+ if (e.nativeEvent && e.nativeEvent.isTrusted === false) {
+ e.preventDefault();
+ flagSuspicious('synthetic-beforeinput', { inputType: e.nativeEvent.inputType });
+ return;
+ }
+ markTrustedInteraction();
+ };
+
+ const handleKeyDownGuard = (e) => {
+ if (anticheatState.locked) {
+ e.preventDefault();
+ return;
+ }
+ const nativeEvent = e.nativeEvent || e;
+ if (nativeEvent && nativeEvent.isTrusted === false) {
+ e.preventDefault();
+ flagSuspicious('synthetic-keydown', { key: e.key });
+ return;
+ }
+ markTrustedInteraction();
+ };
+
// Handle typing input with word locking (snippet) or free-typing (timed)
const handleComponentInput = (e) => {
+ if (anticheatState.locked) {
+ e.preventDefault();
+ return;
+ }
+ const nativeEvent = e.nativeEvent;
+ if (nativeEvent && nativeEvent.isTrusted === false) {
+ e.preventDefault();
+ flagSuspicious('synthetic-input-change', { length: e.target?.value?.length ?? 0 });
+ return;
+ }
+ markTrustedInteraction();
const newInput = e.target.value;
const text = raceState.snippet?.text || '';
const isTimedMode = !!(raceState.snippet?.is_timed_test);
@@ -738,6 +776,12 @@ function Typing({
setInput(typingState.input);
}
}, [typingState.input, raceState.inProgress, raceState.snippet?.is_timed_test]);
+
+ useEffect(() => {
+ if (anticheatState.locked && inputRef.current) {
+ inputRef.current.blur();
+ }
+ }, [anticheatState.locked]);
// Prevent paste
const handlePaste = (e) => {
@@ -1163,11 +1207,15 @@ function Typing({
)}
+ {anticheatState.locked && (
+
+ automated input blocked
+ {anticheatState.message || 'suspicious typing blocked to protect races'}
+
+ )}
+
{renderPracticeTooltip()}
>
);
diff --git a/client/src/context/AuthContext.jsx b/client/src/context/AuthContext.jsx
index 706d83a1..14dc1973 100644
--- a/client/src/context/AuthContext.jsx
+++ b/client/src/context/AuthContext.jsx
@@ -90,7 +90,7 @@ export const AuthProvider = ({ children }) => {
// Monitor socket connection status to update user data on reconnect
useEffect(() => {
let isInitialConnection = true;
- const handleSocketConnect = () => {
+ const handleSocketReconnect = () => {
if (authenticated && !isInitialConnection) {
console.log('Socket reconnected, refreshing user profile data');
fetchUserProfile();
@@ -98,14 +98,9 @@ export const AuthProvider = ({ children }) => {
isInitialConnection = false;
};
- if (window.socket) {
- window.socket.on('connect', handleSocketConnect);
- }
-
+ window.addEventListener('tigertype:connect', handleSocketReconnect);
return () => {
- if (window.socket) {
- window.socket.off('connect', handleSocketConnect);
- }
+ window.removeEventListener('tigertype:connect', handleSocketReconnect);
};
}, [authenticated, fetchUserProfile]); // Depend on auth status and fetch function
diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx
index e0cd2377..b4192191 100644
--- a/client/src/context/RaceContext.jsx
+++ b/client/src/context/RaceContext.jsx
@@ -1,4 +1,4 @@
-import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useSocket } from './SocketContext';
import { useAuth } from './AuthContext';
@@ -54,6 +54,12 @@ const loadRaceState = () => {
}
};
+const INITIAL_ANTICHEAT_STATE = Object.freeze({
+ locked: false,
+ reasons: [],
+ message: null
+});
+
export const RaceProvider = ({ children }) => {
const { socket, connected } = useSocket();
const { user } = useAuth();
@@ -134,6 +140,49 @@ export const RaceProvider = ({ children }) => {
accuracy: 0,
lockedPosition: 0 // Pos up to which text is locked
});
+ const [anticheatState, setAnticheatState] = useState(INITIAL_ANTICHEAT_STATE);
+ const lastTrustedInteractionRef = useRef(Date.now());
+
+ const markTrustedInteraction = useCallback(() => {
+ lastTrustedInteractionRef.current = Date.now();
+ }, []);
+
+ const resetAnticheatState = useCallback(() => {
+ setAnticheatState(() => ({
+ locked: false,
+ reasons: [],
+ message: null
+ }));
+ lastTrustedInteractionRef.current = Date.now();
+ }, []);
+
+ const flagSuspicious = useCallback((reason, metadata = {}) => {
+ if (!reason) return;
+ let shouldNotifyServer = false;
+
+ setAnticheatState(prev => {
+ const alreadyReported = prev.reasons.some(entry => entry.reason === reason);
+ if (!alreadyReported) {
+ shouldNotifyServer = true;
+ }
+ return {
+ locked: true,
+ reasons: alreadyReported
+ ? prev.reasons
+ : [...prev.reasons, { reason, metadata, at: Date.now(), source: 'client' }],
+ message: metadata?.message || prev.message || 'Suspicious automation detected'
+ };
+ });
+
+ if (shouldNotifyServer && socket && connected) {
+ socket.emit('anticheat:flag', {
+ reason,
+ metadata,
+ code: raceState.code,
+ lobbyId: raceState.lobbyId
+ });
+ }
+ }, [socket, connected, raceState.code, raceState.lobbyId]);
// Initialize inactivity state from session storage or default values
const savedInactivityState = loadInactivityState();
@@ -228,6 +277,7 @@ export const RaceProvider = ({ children }) => {
// Event handlers
const handleRaceJoined = (data) => {
// console.log('Joined race:', data);
+ resetAnticheatState();
setRaceState(prev => ({
...prev,
code: data.code,
@@ -475,6 +525,24 @@ export const RaceProvider = ({ children }) => {
});
};
+ const handleAnticheatLock = (payload = {}) => {
+ const { reason = 'server_lock', details = {}, message } = payload;
+ setAnticheatState(prev => {
+ const alreadyLogged = prev.reasons.some(entry => entry.reason === reason && entry.source === 'server');
+ return {
+ locked: true,
+ reasons: alreadyLogged
+ ? prev.reasons
+ : [...prev.reasons, { reason, metadata: details, at: Date.now(), source: 'server' }],
+ message: message || prev.message || 'Suspicious automation detected by server'
+ };
+ });
+ };
+
+ const handleAnticheatReset = () => {
+ resetAnticheatState();
+ };
+
// Register event listeners
socket.on('race:joined', handleRaceJoined);
socket.on('race:playersUpdate', handlePlayersUpdate);
@@ -494,6 +562,8 @@ export const RaceProvider = ({ children }) => {
socket.on('race:countdown', handleRaceCountdown);
socket.on('lobby:newHost', handleNewHost);
socket.on('race:playerLeft', handlePlayerLeft);
+ socket.on('anticheat:lock', handleAnticheatLock);
+ socket.on('anticheat:reset', handleAnticheatReset);
// Clean up on unmount
return () => {
@@ -515,10 +585,12 @@ export const RaceProvider = ({ children }) => {
socket.off('race:countdown', handleRaceCountdown);
socket.off('lobby:newHost', handleNewHost); // Added cleanup
socket.off('race:playerLeft', handlePlayerLeft);
+ socket.off('anticheat:lock', handleAnticheatLock);
+ socket.off('anticheat:reset', handleAnticheatReset);
socket.off('snippetNotFound', handleSnippetNotFound); // Cleanup snippet not found listener
};
// Add raceState.snippet?.id to dependency array to reset typing state on snippet change
- }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id]);
+ }, [socket, connected, raceState.type, raceState.manuallyStarted, raceState.snippet?.id, resetAnticheatState]);
// Methods for race actions
const joinPracticeMode = () => {
@@ -621,6 +693,9 @@ export const RaceProvider = ({ children }) => {
// Handle text input, enforce word locking (snippet mode) or free-flow (timed mode)
const handleInput = (incomingInput) => {
+ if (anticheatState.locked) {
+ return typingState.input;
+ }
let newInput = incomingInput;
// Disable input handling for non-practice races before countdown begins
if (raceState.type !== 'practice' && !raceState.inProgress && raceState.countdown === null) {
@@ -694,6 +769,9 @@ export const RaceProvider = ({ children }) => {
};
const updateProgress = (input) => {
+ if (anticheatState.locked) {
+ return;
+ }
const now = Date.now();
const elapsedSeconds = (now - raceState.startTime) / 1000;
@@ -835,8 +913,11 @@ export const RaceProvider = ({ children }) => {
code: raceState.code,
position: progressPosition,
total: text.length,
- isCompleted,
- hasError
+ isCompleted: isCompleted, // Send explicit completion status to server
+ correctChars,
+ errors: totalErrors,
+ accuracy,
+ wpm
});
}
@@ -903,6 +984,8 @@ export const RaceProvider = ({ children }) => {
}
}
+ resetAnticheatState();
+
setRaceState({
code: null,
type: null,
@@ -1070,9 +1153,12 @@ export const RaceProvider = ({ children }) => {
value={{
raceState,
typingState,
+ anticheatState,
inactivityState,
setRaceState,
setTypingState,
+ flagSuspicious,
+ markTrustedInteraction,
setInactivityState,
joinPracticeMode,
joinPublicRace,
diff --git a/client/src/context/SocketContext.jsx b/client/src/context/SocketContext.jsx
index 3e026cc7..1df6259f 100644
--- a/client/src/context/SocketContext.jsx
+++ b/client/src/context/SocketContext.jsx
@@ -17,6 +17,14 @@ const socketOptions = {
timeout: 20000
};
+const dispatchSocketEvent = (eventName, detail = {}) => {
+ try {
+ window.dispatchEvent(new CustomEvent(`tigertype:${eventName}`, { detail }));
+ } catch (err) {
+ console.error('Error dispatching socket event', eventName, err);
+ }
+};
+
export const SocketProvider = ({ children }) => {
const { user, authenticated } = useAuth();
const raceContext = useContext(RaceContext);
@@ -37,14 +45,12 @@ export const SocketProvider = ({ children }) => {
// Initialize Socket.IO connection
const socketInstance = io(socketOptions);
- // Make socket accessible globally for components that need it directly
- window.socket = socketInstance;
-
// Set up event listeners
socketInstance.on('connect', () => {
// console.log('Socket connected successfully with ID:', socketInstance.id);
setConnected(true);
setError(null);
+ dispatchSocketEvent('connect', { id: socketInstance.id });
// Keep window.user up-to-date w/ the latest user data from AuthContext
if (user) {
@@ -55,6 +61,7 @@ export const SocketProvider = ({ children }) => {
socketInstance.on('connect_error', (err) => {
console.error('Socket connection error:', err);
setConnected(false);
+ dispatchSocketEvent('connect-error', { message: err?.message });
if (err && err.message && err.message.includes('Authentication')) {
setError('Authentication required');
@@ -66,6 +73,7 @@ export const SocketProvider = ({ children }) => {
socketInstance.on('disconnect', (reason) => {
// console.log('Socket disconnected:', reason);
setConnected(false);
+ dispatchSocketEvent('disconnect', { id: socketInstance.id, reason });
if (reason === 'io server disconnect') {
// The server has disconnected us, attempt to reconnect
@@ -86,6 +94,7 @@ export const SocketProvider = ({ children }) => {
alert(reason); // Show alert popup
setConnected(false);
setError(reason);
+ dispatchSocketEvent('forced-disconnect', { id: socketInstance.id, reason });
// Reset race state if RaceContext is available
if (raceContext && raceContext.resetRace) {
@@ -106,10 +115,6 @@ export const SocketProvider = ({ children }) => {
// Clean up on unmount
return () => {
- if (window.socket) {
- window.socket = null;
- }
-
socketInstance.off('connect');
socketInstance.off('disconnect');
socketInstance.off('connect_error');
@@ -154,4 +159,4 @@ export const useSocket = () => {
return context;
};
-export default SocketContext;
\ No newline at end of file
+export default SocketContext;
diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js
index da7596dd..7ab906c3 100644
--- a/server/controllers/socket-handlers.js
+++ b/server/controllers/socket-handlers.js
@@ -29,6 +29,13 @@ const inactivityTimers = new Map(); // Store timers for inactivity warnings and
// Store user avatar URLs for quicker lookup
const playerAvatars = new Map(); // socketId -> avatar_url
+// Anticheat thresholds and state
+const suspiciousPlayers = new Map(); // socketId -> { reasons: [], locked: boolean }
+const MAX_PROGRESS_STEP = 20; // max characters allowed per progress update
+const MIN_PROGRESS_INTERVAL = 25; // min ms between progress packets
+const MAX_ALLOWED_WPM = 320; // anything above is flagged
+const MIN_COMPLETION_TIME_MS = 2500; // cannot finish faster than this
+
// Store host disconnect timers for private lobbies
const HOST_RECONNECT_GRACE_PERIOD = 15000; // 15 seconds
const hostDisconnectTimers = new Map(); // lobbyCode -> { timer: NodeJS.Timeout, userId: number }
@@ -315,6 +322,46 @@ const initialize = (io) => {
console.log(`Socket connected: ${netid} (${socket.id})`);
+ const registerSuspicion = (reason, details = {}) => {
+ if (!reason) return;
+ const existing = suspiciousPlayers.get(socket.id) || { reasons: [], locked: false };
+ const already = existing.reasons.some(entry => entry.reason === reason);
+ if (!already) {
+ existing.reasons.push({ reason, details, at: Date.now() });
+ }
+ existing.locked = true;
+ suspiciousPlayers.set(socket.id, existing);
+
+ const progress = playerProgress.get(socket.id) || {};
+ progress.suspicious = true;
+ progress.suspicionReasons = existing.reasons;
+ playerProgress.set(socket.id, progress);
+
+ console.warn(`[ANTICHEAT] Locked socket ${socket.id} (${netid}) for ${reason}`, details);
+ socket.emit('anticheat:lock', {
+ reason,
+ details,
+ message: details?.message || 'Suspicious typing detected. Automation is not allowed.'
+ });
+ };
+
+ const isSocketLocked = () => {
+ const entry = suspiciousPlayers.get(socket.id);
+ return entry?.locked;
+ };
+
+ socket.on('anticheat:flag', (payload = {}) => {
+ try {
+ const { reason, metadata } = payload || {};
+ if (typeof reason !== 'string' || !reason) {
+ return;
+ }
+ registerSuspicion(`client-${reason}`, metadata || {});
+ } catch (err) {
+ console.error('Error processing anticheat flag from client:', err);
+ }
+ });
+
// Fetch user avatar when connecting
if (userId) {
fetchUserAvatar(userId, socket.id);
@@ -1317,14 +1364,43 @@ const initialize = (io) => {
});
// Handle progress updates
- socket.on('race:progress', (data) => {
+ socket.on('race:progress', (data = {}) => {
try {
- // Client sends { position, total, isCompleted }
- const { code, position, isCompleted, hasError = false } = data;
+ if (isSocketLocked()) {
+ const entry = suspiciousPlayers.get(socket.id);
+ if (entry && entry.reasons.length > 0) {
+ const lastReason = entry.reasons[entry.reasons.length - 1];
+ socket.emit('anticheat:lock', {
+ reason: lastReason.reason,
+ details: lastReason.details,
+ message: lastReason.details?.message || 'Suspicious typing detected. Automation is not allowed.'
+ });
+ }
+ return;
+ }
+
+ const {
+ code,
+ position,
+ isCompleted,
+ accuracy,
+ errors,
+ correctChars,
+ hasError,
+ wpm: clientReportedWpm
+ } = data;
+
+ if (typeof code !== 'string') {
+ return;
+ }
+
+ if (!Number.isFinite(position)) {
+ registerSuspicion('invalid-progress-payload', { position });
+ return;
+ }
- // Check if race exists and is active
const race = activeRaces.get(code);
- if (!race || race.status !== 'racing') {
+ if (!race || race.status !== 'racing' || !race.snippet || !race.snippet.text) {
return;
}
@@ -1335,35 +1411,76 @@ const initialize = (io) => {
}
const playerIndex = players.findIndex(p => p.id === socket.id);
-
if (playerIndex === -1) {
return;
}
- // Throttle progress updates
const now = Date.now();
- const lastUpdate = lastProgressUpdate.get(socket.id) || 0;
+ const snippetLength = race.snippet.text.length;
+ const prevProgress = playerProgress.get(socket.id) || {};
+ const prevPosition = prevProgress.position || 0;
+ const currentHasError = typeof hasError === 'boolean' ? hasError : prevProgress.hasError === true;
+ const delta = position - prevPosition;
- // Allow immediate update if player just completed the race
- if (now - lastUpdate < PROGRESS_THROTTLE && !isCompleted) {
+ if (delta < 0) {
+ registerSuspicion('negative-progress', { prevPosition, position });
return;
}
-
+
+ if (delta > MAX_PROGRESS_STEP) {
+ registerSuspicion('progress-spike', { prevPosition, position, delta });
+ return;
+ }
+
+ const lastUpdateTs = lastProgressUpdate.get(socket.id) || 0;
+ const interval = now - lastUpdateTs;
+
+ if (interval < MIN_PROGRESS_INTERVAL && !isCompleted) {
+ registerSuspicion('progress-interval', { interval });
+ return;
+ }
+
+ if (interval < PROGRESS_THROTTLE && !isCompleted) {
+ return;
+ }
+
lastProgressUpdate.set(socket.id, now);
-
- // Validate the progress (ensure position is not negative or excessively large)
- const snippetLength = race.snippet.text.length;
- if (position < 0 || position > snippetLength) {
- console.warn(`Invalid position from ${netid}: ${position}, snippet length: ${snippetLength}`);
+
+ const allowableOverflow = Math.max(10, Math.floor(snippetLength * 0.1));
+ if (position < 0 || position > snippetLength + allowableOverflow) {
+ registerSuspicion('progress-out-of-range', { position, snippetLength });
return;
}
+
+ const raceStart = race.startTime || now;
+ const elapsedMs = now - raceStart;
+ if (elapsedMs > 0) {
+ const elapsedMinutes = elapsedMs / 60000;
+ const computedWpm = elapsedMinutes > 0 ? (position / 5) / elapsedMinutes : 0;
+ if (computedWpm > MAX_ALLOWED_WPM) {
+ registerSuspicion('wpm-threshold', { computedWpm, position, elapsedMs });
+ return;
+ }
+ if (isCompleted && snippetLength > 40 && elapsedMs < MIN_COMPLETION_TIME_MS) {
+ registerSuspicion('completion-too-fast', { elapsedMs, snippetLength });
+ return;
+ }
+ }
+
+ const history = Array.isArray(prevProgress.history) ? prevProgress.history : [];
+ history.push({ position, timestamp: now });
+ const trimmedHistory = history.slice(-180);
- // Store player progress, using the client-provided completion status
playerProgress.set(socket.id, {
position,
- completed: isCompleted, // Use the client-provided completion status
- hasError: !!hasError,
- timestamp: now
+ completed: isCompleted,
+ timestamp: now,
+ accuracy: Number.isFinite(accuracy) ? accuracy : prevProgress.accuracy,
+ errors: Number.isFinite(errors) ? errors : prevProgress.errors,
+ correctChars: Number.isFinite(correctChars) ? correctChars : prevProgress.correctChars,
+ hasError: currentHasError,
+ wpm: Number.isFinite(clientReportedWpm) ? clientReportedWpm : prevProgress.wpm,
+ history: trimmedHistory
});
// Calculate completion percentage
@@ -1375,16 +1492,15 @@ const initialize = (io) => {
position,
percentage,
completed: isCompleted,
- hasError: !!hasError
+ hasError: currentHasError
});
// Handle race completion for this player if they just completed
if (isCompleted) {
console.log(`User ${netid} has completed the race in lobby ${code} based on progress update`);
- // Ensure finish handler isn't called multiple times if progress updates arrive late
const progressData = playerProgress.get(socket.id);
if (progressData && !progressData.finishHandled) {
- progressData.finishHandled = true; // Mark finish as handled
+ progressData.finishHandled = true;
playerProgress.set(socket.id, progressData);
handlePlayerFinish(io, code, socket.id, progressData).catch(err => {
console.error('Error handling player finish:', err);
@@ -1397,87 +1513,127 @@ const initialize = (io) => {
});
// Handle race result submission
- socket.on('race:result', async (data) => {
+ socket.on('race:result', async (data = {}) => {
try {
const { code, lobbyId, snippetId, wpm, accuracy, completion_time } = data;
const { user: netid, userId } = socket.userInfo;
- // --- BEGIN DEBUG LOGGING ---
- // console.log(`[DEBUG race:result] Received data:`, JSON.stringify(data));
- // console.log(`[DEBUG race:result] User info: netid=${netid}, userId=${userId}`);
- // --- END DEBUG LOGGING ---
-
if (!userId) {
console.error(`[ERROR race:result] Cannot record result: No userId for socket ${socket.id} (netid: ${netid})`);
return;
}
+ if (isSocketLocked()) {
+ console.warn(`[ANTICHEAT] Result rejected for locked socket ${socket.id} (${netid})`);
+ return;
+ }
+
console.log(`Received result from ${netid}: WPM ${wpm}, Acc ${accuracy}, Time ${completion_time}`);
- // Retrieve race and player info
const players = racePlayers.get(code);
const player = players?.find(p => p.id === socket.id);
const race = activeRaces.get(code);
- // Skip base stat updates for private lobbies
const isPrivate = race?.type === 'private';
-
- // --- BEGIN DEBUG LOGGING ---
- // console.log(`[DEBUG race:result] Found player: ${!!player}, Found race: ${!!race}`);
- if (race) {
- // console.log(`[DEBUG race:result] Race snippet info: is_timed=${race.snippet?.is_timed_test}, duration=${race.snippet?.duration}`);
- }
- // --- END DEBUG LOGGING ---
if (!player || !race) {
console.warn(`[WARN race:result] Received result for race ${code}, but player ${netid} or race not found`);
return;
}
- // Check if the result is for a timed test
+ const progressRecord = playerProgress.get(socket.id) || {};
+ if (progressRecord.suspicious) {
+ registerSuspicion('suspicious-progress-result', { reasons: progressRecord.suspicionReasons });
+ return;
+ }
+
+ const finishTimestamp = progressRecord.timestamp || Date.now();
+ const raceStart = race.startTime || finishTimestamp;
+
+ let computedWpm = Number.isFinite(progressRecord.wpm) ? progressRecord.wpm : (Number.isFinite(wpm) ? Number(wpm) : 0);
+ let computedAccuracy = Number.isFinite(progressRecord.accuracy) ? progressRecord.accuracy : (Number.isFinite(accuracy) ? Number(accuracy) : 0);
+ let computedCompletion = Number.isFinite(completion_time) ? Number(completion_time) : null;
+
+ if (!race.snippet?.is_timed_test) {
+ const chars = Number.isFinite(progressRecord.position) ? progressRecord.position : 0;
+ const elapsedMinutes = (finishTimestamp - raceStart) / 60000;
+ if (elapsedMinutes > 0 && chars >= 0) {
+ computedWpm = (chars / 5) / elapsedMinutes;
+ }
+ if (!Number.isFinite(computedCompletion)) {
+ computedCompletion = Math.max(0, (finishTimestamp - raceStart) / 1000);
+ }
+ } else {
+ if (!Number.isFinite(computedCompletion) && Number.isFinite(race.snippet?.duration)) {
+ computedCompletion = Number(race.snippet.duration);
+ }
+ if (!Number.isFinite(computedWpm) && Number.isFinite(wpm)) {
+ computedWpm = Number(wpm);
+ }
+ if (!Number.isFinite(computedAccuracy) && Number.isFinite(accuracy)) {
+ computedAccuracy = Number(accuracy);
+ }
+ }
+
+ computedWpm = Number.isFinite(computedWpm) ? Math.max(0, Math.round(computedWpm * 100) / 100) : 0;
+ computedAccuracy = Number.isFinite(computedAccuracy)
+ ? Math.max(0, Math.min(100, Math.round(computedAccuracy * 100) / 100))
+ : 0;
+ if (!Number.isFinite(computedCompletion)) {
+ computedCompletion = Math.max(0, (finishTimestamp - raceStart) / 1000);
+ }
+
+ if (Number.isFinite(wpm) && Math.abs(Number(wpm) - computedWpm) > 25) {
+ registerSuspicion('wpm-mismatch', { reported: wpm, computed: computedWpm });
+ return;
+ }
+
+ if (Number.isFinite(accuracy) && Math.abs(Number(accuracy) - computedAccuracy) > 25) {
+ registerSuspicion('accuracy-mismatch', { reported: accuracy, computed: computedAccuracy });
+ return;
+ }
+
+ if (computedWpm > MAX_ALLOWED_WPM) {
+ registerSuspicion('wpm-threshold', { computedWpm });
+ return;
+ }
+
+ playerProgress.set(socket.id, {
+ ...progressRecord,
+ wpm: computedWpm,
+ accuracy: computedAccuracy,
+ completion_time: computedCompletion
+ });
+
if (race.snippet?.is_timed_test && race.snippet?.duration) {
const duration = race.snippet.duration;
- // --- BEGIN DEBUG LOGGING ---
- // console.log(`[DEBUG race:result] Processing as TIMED test. Duration: ${duration}`);
- // console.log(`[DEBUG race:result] Calling insertTimedResult with: userId=${userId}, duration=${duration}, wpm=${wpm}, accuracy=${accuracy}`);
- // --- END DEBUG LOGGING ---
try {
- await insertTimedResult(userId, duration, wpm, accuracy);
+ await insertTimedResult(userId, duration, computedWpm, computedAccuracy);
console.log(`[SUCCESS race:result] Saved timed test result for ${netid} (duration: ${duration})`);
} catch (dbError) {
console.error(`[ERROR race:result] Failed to insert timed result for user ${userId}:`, dbError);
- // Optionally emit an error back to client if needed
}
- // Update base stats only for non-private lobbies
try {
if (!isPrivate) {
- await UserModel.updateStats(userId, wpm, accuracy, true);
- await UserModel.updateFastestWpm(userId, wpm);
- // console.log(`[DEBUG race:result] Updated user stats for ${netid}`);
+ await UserModel.updateStats(userId, computedWpm, computedAccuracy, true);
+ await UserModel.updateFastestWpm(userId, computedWpm);
}
} catch (statsError) {
console.error(`[ERROR race:result] Failed to update user stats for ${userId} after timed result:`, statsError);
}
} else if (snippetId) {
- // --- BEGIN DEBUG LOGGING ---
- // console.log(`[DEBUG race:result] Processing as REGULAR race. Snippet ID: ${snippetId}`);
- // console.log(`[DEBUG race:result] Calling RaceModel.recordResult with: userId=${userId}, lobbyId=${lobbyId}, snippetId=${snippetId}, wpm=${wpm}, accuracy=${accuracy}, completion_time=${completion_time}`);
- // --- END DEBUG LOGGING ---
- // Regular race result, save to race_results table
try {
- await RaceModel.recordResult(userId, lobbyId, snippetId, wpm, accuracy, completion_time);
+ await RaceModel.recordResult(userId, lobbyId, snippetId, computedWpm, computedAccuracy, computedCompletion);
console.log(`[SUCCESS race:result] Saved regular race result for ${netid} (lobby: ${lobbyId}, snippet: ${snippetId})`);
} catch (dbError) {
console.error(`[ERROR race:result] Failed to insert regular race result for user ${userId}:`, dbError);
}
- // Update base stats only for non-private lobbies
try {
if (!isPrivate) {
- await UserModel.updateStats(userId, wpm, accuracy, false);
- await UserModel.updateFastestWpm(userId, wpm);
- // console.log(`[DEBUG race:result] Updated user stats for ${netid}`);
+ await UserModel.updateStats(userId, computedWpm, computedAccuracy, false);
+ await UserModel.updateFastestWpm(userId, computedWpm);
}
} catch (statsError) {
console.error(`[ERROR race:result] Failed to update user stats for ${userId} after regular result:`, statsError);
@@ -1486,19 +1642,18 @@ const initialize = (io) => {
console.warn(`[WARN race:result] Result from ${netid} for race ${code} has no snippetId and is not a timed test.`);
}
- // Handle player finish logic (updates progress, checks if race ends)
- // Wrap in try/catch as well
try {
- await handlePlayerFinish(io, code, socket.id, { wpm, accuracy, completion_time });
- // console.log(`[DEBUG race:result] Successfully handled player finish logic for ${netid}`);
+ await handlePlayerFinish(io, code, socket.id, {
+ wpm: computedWpm,
+ accuracy: computedAccuracy,
+ completion_time: computedCompletion
+ });
} catch (finishError) {
console.error(`[ERROR race:result] Error in handlePlayerFinish for ${netid}:`, finishError);
}
} catch (err) {
console.error('[ERROR race:result] General error in handler:', err);
- // Avoid emitting generic error to prevent potential info leak
- // socket.emit('error', { message: 'Failed to save race result' });
}
});
@@ -1725,6 +1880,7 @@ const initialize = (io) => {
// Clean up any stored progress
playerProgress.delete(socket.id);
lastProgressUpdate.delete(socket.id);
+ suspiciousPlayers.delete(socket.id);
// Clean up stored avatar
playerAvatars.delete(socket.id);
// Clean up any inactivity timers associated with this specific socket ID across all lobbies