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