kimi gone wild
This commit is contained in:
891
lib/odinsea/anticheat/monitor.ex
Normal file
891
lib/odinsea/anticheat/monitor.ex
Normal file
@@ -0,0 +1,891 @@
|
||||
defmodule Odinsea.AntiCheat.CheatTracker do
|
||||
@moduledoc """
|
||||
Main cheat tracking module per character.
|
||||
|
||||
Ported from: client.anticheat.CheatTracker.java
|
||||
|
||||
This is a GenServer that tracks all anti-cheat state for a single character:
|
||||
- Offense history with expiration
|
||||
- Attack timing tracking (speed hack detection)
|
||||
- Damage validation state
|
||||
- Movement validation state
|
||||
- Drop/message rate limiting
|
||||
- GM alerts for suspicious activity
|
||||
|
||||
## State Structure
|
||||
|
||||
- `offenses`: Map of offense_type => CheatingOffenseEntry
|
||||
- `character_id`: The character being tracked
|
||||
- `character_pid`: PID of the character GenServer
|
||||
- `last_attack_time`: Timestamp of last attack
|
||||
- `last_attack_tick_count`: Client tick count at last attack
|
||||
- `attack_tick_reset_count`: Counter for tick synchronization
|
||||
- `server_client_atk_tick_diff`: Time difference tracker
|
||||
- `last_damage`: Last damage dealt
|
||||
- `taking_damage_since`: When continuous damage started
|
||||
- `num_sequential_damage`: Count of sequential damage events
|
||||
- `last_damage_taken_time`: Timestamp of last damage taken
|
||||
- `num_zero_damage_taken`: Count of zero damage events (avoid)
|
||||
- `num_same_damage`: Count of identical damage values
|
||||
- `drops_per_second`: Drop rate counter
|
||||
- `last_drop_time`: Timestamp of last drop
|
||||
- `msgs_per_second`: Message rate counter
|
||||
- `last_msg_time`: Timestamp of last message
|
||||
- `attacks_without_hit`: Counter for attacks without being hit
|
||||
- `gm_message`: Counter for GM alerts
|
||||
- `last_tick_count`: Last client tick seen
|
||||
- `tick_same`: Counter for duplicate ticks (packet spam)
|
||||
- `last_smega_time`: Last super megaphone use
|
||||
- `last_avatar_smega_time`: Last avatar megaphone use
|
||||
- `last_bbs_time`: Last BBS use
|
||||
- `summon_summon_time`: Summon activation time
|
||||
- `num_sequential_summon_attack`: Sequential summon attack count
|
||||
- `familiar_summon_time`: Familiar activation time
|
||||
- `num_sequential_familiar_attack`: Sequential familiar attack count
|
||||
- `last_monster_move`: Last monster position
|
||||
- `monster_move_count`: Monster move counter
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.AntiCheat.{CheatingOffense, CheatingOffenseEntry, AutobanManager}
|
||||
alias Odinsea.Constants.Game
|
||||
|
||||
@table :cheat_trackers
|
||||
|
||||
# GM alert threshold - broadcast every 100 offenses
|
||||
@gm_alert_threshold 100
|
||||
@gm_autoban_threshold 300
|
||||
|
||||
# Client/Server time difference threshold (ms)
|
||||
@time_diff_threshold 1000
|
||||
|
||||
# How often to run invalidation task (ms)
|
||||
@invalidation_interval 60_000
|
||||
|
||||
# =============================================================================
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Starts a cheat tracker for a character.
|
||||
"""
|
||||
def start_tracker(character_id, character_pid) do
|
||||
# Ensure ETS table exists
|
||||
case :ets.info(@table) do
|
||||
:undefined ->
|
||||
:ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.CheatTrackerSupervisor,
|
||||
{__MODULE__, {character_id, character_pid}}
|
||||
) do
|
||||
{:ok, pid} ->
|
||||
:ets.insert(@table, {character_id, pid})
|
||||
{:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the cheat tracker for a character.
|
||||
"""
|
||||
def stop_tracker(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid ->
|
||||
GenServer.stop(pid, :normal)
|
||||
:ets.delete(@table, character_id)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a tracker PID by character ID.
|
||||
"""
|
||||
def lookup_tracker(character_id) do
|
||||
case :ets.lookup(@table, character_id) do
|
||||
[{^character_id, pid}] -> pid
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a cheating offense for a character.
|
||||
"""
|
||||
def register_offense(character_id, offense, param \\ nil) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :error
|
||||
pid -> GenServer.call(pid, {:register_offense, offense, param})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total cheat points for a character.
|
||||
"""
|
||||
def get_points(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> 0
|
||||
pid -> GenServer.call(pid, :get_points)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a summary of offenses for a character.
|
||||
"""
|
||||
def get_summary(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> ""
|
||||
pid -> GenServer.call(pid, :get_summary)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all active offenses for a character.
|
||||
"""
|
||||
def get_offenses(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> %{}
|
||||
pid -> GenServer.call(pid, :get_offenses)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks attack timing for speed hack detection.
|
||||
"""
|
||||
def check_attack(character_id, skill_id, tick_count) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_attack, skill_id, tick_count})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks damage taken rate.
|
||||
"""
|
||||
def check_take_damage(character_id, damage) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_take_damage, damage})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks for same damage values (damage hack detection).
|
||||
"""
|
||||
def check_same_damage(character_id, damage, expected) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_same_damage, damage, expected})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks drop rate.
|
||||
"""
|
||||
def check_drop(character_id, dc \\ false) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_drop, dc})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks message rate.
|
||||
"""
|
||||
def check_message(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :check_message)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use super megaphone.
|
||||
"""
|
||||
def can_smega(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_smega)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use avatar megaphone.
|
||||
"""
|
||||
def can_avatar_smega(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_avatar_smega)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use BBS.
|
||||
"""
|
||||
def can_bbs(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_bbs)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates client tick count and detects packet spam.
|
||||
"""
|
||||
def update_tick(character_id, new_tick) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:update_tick, new_tick})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks summon attack rate.
|
||||
"""
|
||||
def check_summon_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :check_summon_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets summon attack tracking.
|
||||
"""
|
||||
def reset_summon_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :reset_summon_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks familiar attack rate.
|
||||
"""
|
||||
def check_familiar_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :check_familiar_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets familiar attack tracking.
|
||||
"""
|
||||
def reset_familiar_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :reset_familiar_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets attacks without hit counter.
|
||||
"""
|
||||
def set_attacks_without_hit(character_id, increase) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:set_attacks_without_hit, increase})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets attacks without hit count.
|
||||
"""
|
||||
def get_attacks_without_hit(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> 0
|
||||
pid -> GenServer.call(pid, :get_attacks_without_hit)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks for suspicious monster movement (move monster hack).
|
||||
"""
|
||||
def check_move_monsters(character_id, position) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_move_monsters, position})
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# GenServer Callbacks
|
||||
# =============================================================================
|
||||
|
||||
def start_link({character_id, character_pid}) do
|
||||
GenServer.start_link(__MODULE__, {character_id, character_pid})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({character_id, character_pid}) do
|
||||
# Start invalidation timer
|
||||
schedule_invalidation()
|
||||
|
||||
{:ok, %{
|
||||
character_id: character_id,
|
||||
character_pid: character_pid,
|
||||
offenses: %{},
|
||||
|
||||
# Attack timing
|
||||
last_attack_time: 0,
|
||||
last_attack_tick_count: 0,
|
||||
attack_tick_reset_count: 0,
|
||||
server_client_atk_tick_diff: 0,
|
||||
|
||||
# Damage tracking
|
||||
last_damage: 0,
|
||||
taking_damage_since: System.monotonic_time(:millisecond),
|
||||
num_sequential_damage: 0,
|
||||
last_damage_taken_time: 0,
|
||||
num_zero_damage_taken: 0,
|
||||
num_same_damage: 0,
|
||||
|
||||
# Rate limiting
|
||||
drops_per_second: 0,
|
||||
last_drop_time: 0,
|
||||
msgs_per_second: 0,
|
||||
last_msg_time: 0,
|
||||
|
||||
# Combat tracking
|
||||
attacks_without_hit: 0,
|
||||
|
||||
# GM alerts
|
||||
gm_message: 0,
|
||||
|
||||
# Tick tracking
|
||||
last_tick_count: 0,
|
||||
tick_same: 0,
|
||||
|
||||
# Megaphone/BBS tracking
|
||||
last_smega_time: 0,
|
||||
last_avatar_smega_time: 0,
|
||||
last_bbs_time: 0,
|
||||
|
||||
# Summon tracking
|
||||
summon_summon_time: 0,
|
||||
num_sequential_summon_attack: 0,
|
||||
|
||||
# Familiar tracking
|
||||
familiar_summon_time: 0,
|
||||
num_sequential_familiar_attack: 0,
|
||||
|
||||
# Monster movement tracking
|
||||
last_monster_move: nil,
|
||||
monster_move_count: 0
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:register_offense, offense, param}, _from, state) do
|
||||
new_state = do_register_offense(state, offense, param)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_points, _from, state) do
|
||||
points = calculate_points(state)
|
||||
{:reply, points, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_summary, _from, state) do
|
||||
summary = build_summary(state)
|
||||
{:reply, summary, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_offenses, _from, state) do
|
||||
{:reply, state.offenses, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_attack, skill_id, tick_count}, _from, state) do
|
||||
new_state = check_attack_timing(state, skill_id, tick_count)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_take_damage, damage}, _from, state) do
|
||||
new_state = check_damage_taken(state, damage)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_same_damage, damage, expected}, _from, state) do
|
||||
new_state = check_same_damage_value(state, damage, expected)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_drop, dc}, _from, state) do
|
||||
new_state = check_drop_rate(state, dc)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_message, _from, state) do
|
||||
new_state = check_msg_rate(state)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_smega, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_smega_time >= 15_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_smega_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_avatar_smega, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_avatar_smega_time >= 300_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_avatar_smega_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_bbs, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_bbs_time >= 60_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_bbs_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_tick, new_tick}, _from, state) do
|
||||
new_state = handle_tick_update(state, new_tick)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_summon_attack, _from, state) do
|
||||
{result, new_state} = check_summon_timing(state)
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reset_summon_attack, _from, state) do
|
||||
new_state = %{state |
|
||||
summon_summon_time: System.monotonic_time(:millisecond),
|
||||
num_sequential_summon_attack: 0
|
||||
}
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_familiar_attack, _from, state) do
|
||||
{result, new_state} = check_familiar_timing(state)
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reset_familiar_attack, _from, state) do
|
||||
new_state = %{state |
|
||||
familiar_summon_time: System.monotonic_time(:millisecond),
|
||||
num_sequential_familiar_attack: 0
|
||||
}
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_attacks_without_hit, increase}, _from, state) do
|
||||
new_count = if increase do
|
||||
state.attacks_without_hit + 1
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
new_state = %{state | attacks_without_hit: new_count}
|
||||
{:reply, new_count, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_attacks_without_hit, _from, state) do
|
||||
{:reply, state.attacks_without_hit, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_move_monsters, position}, _from, state) do
|
||||
new_state = check_monster_movement(state, position)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:invalidate_offenses, state) do
|
||||
new_offenses =
|
||||
state.offenses
|
||||
|> Enum.reject(fn {_type, entry} -> CheatingOffenseEntry.expired?(entry) end)
|
||||
|> Map.new()
|
||||
|
||||
# Check if character still exists
|
||||
if Process.alive?(state.character_pid) do
|
||||
schedule_invalidation()
|
||||
{:noreply, %{state | offenses: new_offenses}}
|
||||
else
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_msg, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Private Functions
|
||||
# =============================================================================
|
||||
|
||||
defp schedule_invalidation do
|
||||
Process.send_after(self(), :invalidate_offenses, @invalidation_interval)
|
||||
end
|
||||
|
||||
defp do_register_offense(state, offense, param) do
|
||||
# Skip if offense is disabled
|
||||
if CheatingOffense.is_enabled?(offense) do
|
||||
# Check if we already have an entry
|
||||
entry = Map.get(state.offenses, offense)
|
||||
|
||||
# Expire old entry if needed
|
||||
entry = if entry && CheatingOffenseEntry.expired?(entry) do
|
||||
nil
|
||||
else
|
||||
entry
|
||||
end
|
||||
|
||||
# Create new entry if needed
|
||||
entry = entry || CheatingOffenseEntry.new(offense, state.character_id)
|
||||
|
||||
# Set param if provided
|
||||
entry = if param, do: CheatingOffenseEntry.set_param(entry, param), else: entry
|
||||
|
||||
# Increment count
|
||||
entry = CheatingOffenseEntry.increment(entry)
|
||||
|
||||
# Check for autoban
|
||||
state = check_autoban(state, offense, entry, param)
|
||||
|
||||
# Store entry
|
||||
offenses = Map.put(state.offenses, offense, entry)
|
||||
|
||||
%{state | offenses: offenses}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp check_autoban(state, offense, entry, param) do
|
||||
if CheatingOffense.should_autoban?(offense, entry.count) do
|
||||
ban_type = CheatingOffense.get_ban_type(offense)
|
||||
|
||||
case ban_type do
|
||||
:ban ->
|
||||
AutobanManager.autoban(state.character_id, offense_name(offense))
|
||||
|
||||
:disconnect ->
|
||||
# Log DC attempt
|
||||
Logger.warning("[AntiCheat] DC triggered for char #{state.character_id}: #{offense_name(offense)}")
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
%{state | gm_message: 0}
|
||||
else
|
||||
# Check for GM alerts on certain offenses
|
||||
check_gm_alert(state, offense, entry, param)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_gm_alert(state, offense, entry, param) do
|
||||
alert_offenses = [
|
||||
:high_damage_magic_2,
|
||||
:high_damage_2,
|
||||
:attack_faraway_monster,
|
||||
:attack_faraway_monster_summon,
|
||||
:same_damage
|
||||
]
|
||||
|
||||
if offense in alert_offenses do
|
||||
new_gm_count = state.gm_message + 1
|
||||
|
||||
# Broadcast to GMs every 100 occurrences
|
||||
if rem(new_gm_count, @gm_alert_threshold) == 0 do
|
||||
msg = "#{state.character_id} is suspected of hacking! #{offense_name(offense)}"
|
||||
msg = if param, do: "#{msg} - #{param}", else: msg
|
||||
broadcast_gm_alert(msg)
|
||||
end
|
||||
|
||||
# Check for autoban after 300 offenses
|
||||
if new_gm_count >= @gm_autoban_threshold do
|
||||
Logger.warning("[AntiCheat] High offense count for char #{state.character_id}")
|
||||
end
|
||||
|
||||
%{state | gm_message: new_gm_count}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_gm_alert(_msg) do
|
||||
# TODO: Implement GM alert broadcasting through World service
|
||||
:ok
|
||||
end
|
||||
|
||||
defp calculate_points(state) do
|
||||
state.offenses
|
||||
|> Map.values()
|
||||
|> Enum.reject(&CheatingOffenseEntry.expired?/1)
|
||||
|> Enum.map(&CheatingOffenseEntry.get_points/1)
|
||||
|> Enum.sum()
|
||||
end
|
||||
|
||||
defp build_summary(state) do
|
||||
sorted_offenses =
|
||||
state.offenses
|
||||
|> Map.values()
|
||||
|> Enum.reject(&CheatingOffenseEntry.expired?/1)
|
||||
|> Enum.sort_by(&CheatingOffenseEntry.get_points/1, :desc)
|
||||
|> Enum.take(4)
|
||||
|
||||
sorted_offenses
|
||||
|> Enum.map(fn entry ->
|
||||
"#{offense_name(entry.offense_type)}: #{entry.count}"
|
||||
end)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp check_attack_timing(state, skill_id, tick_count) do
|
||||
# Get attack delay for skill
|
||||
atk_delay = Game.get_attack_delay(skill_id)
|
||||
|
||||
# Check for fast attack
|
||||
state = if tick_count - state.last_attack_tick_count < atk_delay do
|
||||
do_register_offense(state, :fast_attack, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
# Update attack time
|
||||
state = %{state | last_attack_time: now}
|
||||
|
||||
# Check server/client tick difference
|
||||
st_time_tc = now - tick_count
|
||||
diff = state.server_client_atk_tick_diff - st_time_tc
|
||||
|
||||
state = if diff > @time_diff_threshold do
|
||||
do_register_offense(state, :fast_attack_2, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
# Update tick counters
|
||||
reset_count = state.attack_tick_reset_count + 1
|
||||
reset_threshold = if atk_delay <= 200, do: 1, else: 4
|
||||
|
||||
if reset_count >= reset_threshold do
|
||||
%{state |
|
||||
attack_tick_reset_count: 0,
|
||||
server_client_atk_tick_diff: st_time_tc,
|
||||
last_attack_tick_count: tick_count
|
||||
}
|
||||
else
|
||||
%{state |
|
||||
attack_tick_reset_count: reset_count,
|
||||
last_attack_tick_count: tick_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_damage_taken(state, damage) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
new_sequential = state.num_sequential_damage + 1
|
||||
|
||||
# Check fast take damage
|
||||
time_since_start = now - state.taking_damage_since
|
||||
|
||||
state = if time_since_start / 500 < new_sequential do
|
||||
do_register_offense(state, :fast_take_damage, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
# Reset if more than 4.5 seconds
|
||||
{new_sequential, new_since} = if time_since_start > 4500 do
|
||||
{0, now}
|
||||
else
|
||||
{new_sequential, state.taking_damage_since}
|
||||
end
|
||||
|
||||
# Track zero damage (avoid hack)
|
||||
new_zero_count = if damage == 0 do
|
||||
state.num_zero_damage_taken + 1
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
%{state |
|
||||
num_sequential_damage: new_sequential,
|
||||
taking_damage_since: new_since,
|
||||
last_damage_taken_time: now,
|
||||
num_zero_damage_taken: new_zero_count
|
||||
}
|
||||
end
|
||||
|
||||
defp check_same_damage_value(state, damage, expected) do
|
||||
# Only check significant damage
|
||||
if damage > 2000 && state.last_damage == damage do
|
||||
new_count = state.num_same_damage + 1
|
||||
|
||||
state = if new_count > 5 do
|
||||
do_register_offense(state, :same_damage,
|
||||
"#{new_count} times, damage #{damage}, expected #{expected}")
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
%{state | num_same_damage: new_count}
|
||||
else
|
||||
%{state |
|
||||
last_damage: damage,
|
||||
num_same_damage: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_drop_rate(state, _dc) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
if now - state.last_drop_time < 1000 do
|
||||
new_drops = state.drops_per_second + 1
|
||||
threshold = 16 # 32 for DC mode
|
||||
|
||||
if new_drops >= threshold do
|
||||
# TODO: Set monitored flag or DC
|
||||
Logger.warning("[AntiCheat] High drop rate for char #{state.character_id}: #{new_drops}/sec")
|
||||
end
|
||||
|
||||
%{state | drops_per_second: new_drops, last_drop_time: now}
|
||||
else
|
||||
%{state | drops_per_second: 0, last_drop_time: now}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_msg_rate(state) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
if now - state.last_msg_time < 1000 do
|
||||
new_msgs = state.msgs_per_second + 1
|
||||
|
||||
if new_msgs > 10 do
|
||||
Logger.warning("[AntiCheat] High message rate for char #{state.character_id}: #{new_msgs}/sec")
|
||||
end
|
||||
|
||||
%{state | msgs_per_second: new_msgs, last_msg_time: now}
|
||||
else
|
||||
%{state | msgs_per_second: 0, last_msg_time: now}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_tick_update(state, new_tick) do
|
||||
if new_tick <= state.last_tick_count do
|
||||
# Packet spamming detected
|
||||
new_same = state.tick_same + 1
|
||||
|
||||
if new_same >= 5 do
|
||||
# TODO: Close session
|
||||
Logger.warning("[AntiCheat] Packet spamming detected for char #{state.character_id}")
|
||||
end
|
||||
|
||||
%{state | tick_same: new_same, last_tick_count: new_tick}
|
||||
else
|
||||
%{state | tick_same: 0, last_tick_count: new_tick}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_summon_timing(state) do
|
||||
new_count = state.num_sequential_summon_attack + 1
|
||||
now = System.monotonic_time(:millisecond)
|
||||
time_diff = now - state.summon_summon_time
|
||||
|
||||
# Allow 1 summon attack per second + 1
|
||||
allowed = div(time_diff, 1000) + 1
|
||||
|
||||
if allowed < new_count do
|
||||
new_state = do_register_offense(state, :fast_summon_attack, nil)
|
||||
{false, %{new_state | num_sequential_summon_attack: new_count}}
|
||||
else
|
||||
{true, %{state | num_sequential_summon_attack: new_count}}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_familiar_timing(state) do
|
||||
new_count = state.num_sequential_familiar_attack + 1
|
||||
now = System.monotonic_time(:millisecond)
|
||||
time_diff = now - state.familiar_summon_time
|
||||
|
||||
# Allow 1 familiar attack per 600ms + 1
|
||||
allowed = div(time_diff, 600) + 1
|
||||
|
||||
if allowed < new_count do
|
||||
new_state = do_register_offense(state, :fast_summon_attack, nil)
|
||||
{false, %{new_state | num_sequential_familiar_attack: new_count}}
|
||||
else
|
||||
{true, %{state | num_sequential_familiar_attack: new_count}}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_monster_movement(state, position) do
|
||||
if state.last_monster_move == position do
|
||||
new_count = state.monster_move_count + 1
|
||||
|
||||
state = if new_count > 10 do
|
||||
do_register_offense(state, :move_monsters, "Position: #{inspect(position)}")
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
%{state | monster_move_count: 0}
|
||||
else
|
||||
%{state |
|
||||
last_monster_move: position,
|
||||
monster_move_count: 1
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp offense_name(offense) do
|
||||
offense
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.capitalize()
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user