kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View 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