Files
Guncircle.io/packages/server/src/validation.ts
T

205 lines
6.7 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════════
// 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 };
}
}