본문으로 건너뛰기

안티치트

Connect Base 게임 서버는 서버 권한 모델을 사용하여 치트를 방지합니다. 모든 게임 로직은 서버에서 검증됩니다.

서버 권한 모델

클라이언트 A    클라이언트 B
     │              │
     ▼              ▼
   액션 전송      액션 전송
     │              │
     └──────┬───────┘
            ▼
      ┌───────────┐
      │ 게임 서버  │ ◀── 검증 및 상태 계산
      └───────────┘
            │
            ▼
       상태 브로드캐스트
            │
     ┌──────┴───────┐
     ▼              ▼
클라이언트 A    클라이언트 B

액션 검증

서버에서 모든 액션을 검증합니다:

typescript
// 서버 측 (Lua 스크립트)
function validateAction(playerId, action, currentState)
  -- 이동 속도 검증
  if action.type == "move" then
    local player = currentState.players[playerId]
    local distance = math.sqrt(
      (action.data.x - player.x)^2 +
      (action.data.y - player.y)^2
    )

    local maxSpeed = 10  -- 최대 이동 속도
    local deltaTime = action.timestamp - player.lastMoveTime
    local maxDistance = maxSpeed * deltaTime

    if distance > maxDistance * 1.1 then  -- 10% 오차 허용
      return false, "speed_hack_detected"
    end
  end

  -- 쿨다운 검증
  if action.type == "use_skill" then
    local skill = skills[action.data.skillId]
    local lastUse = currentState.skillCooldowns[playerId][action.data.skillId]

    if lastUse and (action.timestamp - lastUse) < skill.cooldown then
      return false, "cooldown_not_ready"
    end
  end

  return true
end

레이트 리미팅

초당 액션 수를 제한합니다:

typescript
// 서버 측 설정
const RATE_LIMITS = {
  move: 60,      // 초당 60회 이동
  attack: 10,    // 초당 10회 공격
  chat: 3,       // 초당 3회 채팅
}

// 클라이언트 측에서도 제한
class RateLimiter {
  private counts: Map<string, number[]> = new Map()

  canPerform(actionType: string): boolean {
    const limit = RATE_LIMITS[actionType] || 10
    const now = Date.now()
    const windowMs = 1000

    const timestamps = this.counts.get(actionType) || []
    const recentTimestamps = timestamps.filter(t => now - t < windowMs)

    if (recentTimestamps.length >= limit) {
      return false
    }

    recentTimestamps.push(now)
    this.counts.set(actionType, recentTimestamps)
    return true
  }
}

const rateLimiter = new RateLimiter()

function sendAction(action: GameAction) {
  if (!rateLimiter.canPerform(action.type)) {
    console.warn('Rate limit exceeded')
    return
  }
  game.sendAction(action)
}

시퀀스 검증

액션 순서를 검증합니다:

typescript
// 클라이언트
let actionSequence = 0

function sendAction(action: GameAction) {
  game.sendAction({
    ...action,
    sequence: actionSequence++
  })
}

// 서버 측
function validateSequence(playerId, action, lastSequence)
  if action.sequence <= lastSequence then
    -- 중복 또는 순서 오류
    return false, "invalid_sequence"
  end

  if action.sequence > lastSequence + 100 then
    -- 너무 큰 점프 (의심스러움)
    return false, "sequence_gap_too_large"
  end

  return true
end

히트박스 검증

서버에서 충돌 판정:

typescript
// 서버 측 히트 판정
function validateHit(attacker, target, weapon)
  local distance = getDistance(attacker.position, target.position)

  -- 무기 사거리 확인
  if distance > weapon.range * 1.1 then
    return false, "out_of_range"
  end

  -- 시야선 확인 (벽 뒤 공격 방지)
  if not hasLineOfSight(attacker.position, target.position) then
    return false, "no_line_of_sight"
  end

  -- 히트박스 교차 확인
  if not intersectsHitbox(weapon.hitbox, target.hitbox) then
    return false, "hitbox_miss"
  end

  return true
end

비정상 행동 감지

통계 기반 이상 탐지:

typescript
// 서버 측 통계 수집
const playerStats = new Map<string, PlayerStats>()

function recordAction(playerId: string, action: GameAction) {
  const stats = playerStats.get(playerId) || new PlayerStats()

  stats.actionCount++
  stats.recordAccuracy(action)
  stats.recordReactionTime(action)

  // 비정상 탐지
  if (stats.accuracy > 0.99 && stats.actionCount > 100) {
    flagForReview(playerId, 'suspiciously_high_accuracy')
  }

  if (stats.avgReactionTime < 50) {  // 50ms 미만 반응
    flagForReview(playerId, 'inhuman_reaction_time')
  }
}

리플레이 시스템

의심스러운 플레이를 검토하기 위한 리플레이 저장:

typescript
// 게임 세션 기록
interface GameReplay {
  roomId: string
  startTime: number
  endTime: number
  players: string[]
  events: ReplayEvent[]
}

interface ReplayEvent {
  timestamp: number
  type: 'action' | 'state' | 'player_event'
  data: unknown
}

// 서버에서 리플레이 저장
async function saveReplay(replay: GameReplay) {
  await cb.database.createData('tbl_replays', { data: replay })
}

// 리플레이 재생
async function playReplay(replayId: string) {
  const row = await cb.database.getDataById('tbl_replays', replayId)
  const replay = row.data as unknown as GameReplay

  for (const event of replay.events) {
    await delay(event.timestamp - replay.startTime)
    renderEvent(event)
  }
}