892 lines
23 KiB
Elixir
892 lines
23 KiB
Elixir
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
|