// ═══════════════════════════════════════════════════════════════════════════════ // GunCircle.io — Input Validator (Anti-cheat) // ═══════════════════════════════════════════════════════════════════════════════ import { type Player, type GunConfig, type PlayerInput, PLAYER_BASE_SPEED, PLAYER_ACCELERATION, ARENA_WIDTH, ARENA_HEIGHT, STAT_MAX_LEVEL, STAT_COUNT, Stat, AIM_ANGLE_TOLERANCE, MAX_LEVEL, } from '@guncircle/shared'; // ─── InputValidator ────────────────────────────────────────────────────────── export class InputValidator { /** Maximum position change per tick at max speed */ private readonly maxDistPerTick: number; constructor() { // At 60Hz, max distance per tick = speed / 60 + small tolerance this.maxDistPerTick = PLAYER_BASE_SPEED / 60 + 2; } /** * Validate movement input. Speed must not exceed base * (1 + moveSpeedStat). * Returns clamped (newX, newY) that are within allowed bounds. */ validateMovement( player: Player, moveAngle: number, dt: number ): { x: number; y: number; valid: boolean } { if (moveAngle < -0.5) { // -1 or negative means no movement input return { x: player.x, y: player.y, valid: true }; } const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0; const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05); const maxStep: number = maxSpeed * dt; // Validate position delta from current position const requestedVx: number = Math.cos(moveAngle) * maxSpeed; const requestedVy: number = Math.sin(moveAngle) * maxSpeed; const newX: number = player.x + requestedVx * dt; const newY: number = player.y + requestedVy * dt; // Clamp to arena const clampedX: number = Math.max( player.radius, Math.min(ARENA_WIDTH - player.radius, newX) ); const clampedY: number = Math.max( player.radius, Math.min(ARENA_HEIGHT - player.radius, newY) ); // Check if the step size is reasonable const dx: number = clampedX - player.x; const dy: number = clampedY - player.y; const stepSize: number = Math.sqrt(dx * dx + dy * dy); if (stepSize > maxStep * 1.5) { // Possible speed hack — reject movement return { x: player.x, y: player.y, valid: false }; } return { x: clampedX, y: clampedY, valid: true }; } /** * Validate aim angle change. Change per tick must be within tolerance. */ validateAimAngle(oldAngle: number, newAngle: number): { angle: number; valid: boolean } { let diff: number = newAngle - oldAngle; // Normalize to [-PI, PI] while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; const absDiff: number = Math.abs(diff); if (absDiff > AIM_ANGLE_TOLERANCE * 10) { // Suspiciously large angle change — could be aimbot // Cap it to max allowed const cappedDiff: number = Math.sign(diff) * AIM_ANGLE_TOLERANCE * 10; let cappedAngle: number = oldAngle + cappedDiff; while (cappedAngle > Math.PI) cappedAngle -= Math.PI * 2; while (cappedAngle < -Math.PI) cappedAngle += Math.PI * 2; return { angle: cappedAngle, valid: false }; } return { angle: newAngle, valid: true }; } /** * Validate fire rate: time since last shot must be >= cooldown. * Returns true if firing is allowed. */ validateFireRate( lastShotTime: number, currentTime: number, gun: GunConfig, reloadSpdStat: number ): boolean { const baseCooldown: number = 1000 / gun.fireRate; const reloadBonus: number = 1 + reloadSpdStat * 0.1; const adjustedCooldown: number = baseCooldown / reloadBonus; const elapsed: number = currentTime - lastShotTime; return elapsed >= adjustedCooldown - 5; // 5ms tolerance for network jitter } /** * Validate stat upgrade choice. Returns true if the player can make this upgrade. */ validateUpgrade(player: Player, choice: number): { valid: boolean; reason: string } { // Must have upgrade points if (player.upgradePoints <= 0) { return { valid: false, reason: 'No upgrade points available' }; } // Choice must be a valid stat index if (choice < 0 || choice >= STAT_COUNT) { return { valid: false, reason: 'Invalid stat index' }; } // Stat must not be maxed const currentLevel: number = player.stats[choice] ?? 0; if (currentLevel >= STAT_MAX_LEVEL) { return { valid: false, reason: 'Stat already at max level' }; } return { valid: true, reason: '' }; } /** * Validate branch selection choice. Returns true if branch is valid for player. */ validateBranchChoice( player: Player, branchIndex: number, availableBranches: number[] ): { valid: boolean; reason: string } { if (branchIndex < 0 || branchIndex >= availableBranches.length) { return { valid: false, reason: 'Invalid branch selection' }; } if (player.upgradePoints <= 0) { return { valid: false, reason: 'No upgrade points available' }; } return { valid: true, reason: '' }; } /** * Validate general input bounds. Clamp numeric values to reasonable ranges. */ sanitizeInput(input: PlayerInput): PlayerInput { return { seq: Math.max(0, input.seq | 0), moveAngle: isNaN(input.moveAngle) ? -1 : input.moveAngle, aimAngle: isNaN(input.aimAngle) ? 0 : input.aimAngle, isShooting: Boolean(input.isShooting), upgradeChoice: input.upgradeChoice !== undefined ? Math.max(0, input.upgradeChoice | 0) : undefined, }; } /** * Check if a position is within arena bounds. */ isInArena(x: number, y: number, radius: number = 0): boolean { return ( x >= radius && x <= ARENA_WIDTH - radius && y >= radius && y <= ARENA_HEIGHT - radius ); } /** * Validate that velocity is within reasonable bounds. */ validateVelocity(vx: number, vy: number, maxSpeed: number): { vx: number; vy: number } { const speedSq: number = vx * vx + vy * vy; const maxSpeedSq: number = maxSpeed * maxSpeed; if (speedSq > maxSpeedSq * 4) { // Suspicious velocity — cap it const speed: number = Math.sqrt(speedSq); const scale: number = (maxSpeed * 2) / speed; return { vx: vx * scale, vy: vy * scale }; } return { vx, vy }; } }