205 lines
6.7 KiB
TypeScript
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 };
|
|
}
|
|
}
|