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,250 @@
defmodule Odinsea.AntiCheat.AutobanManager do
@moduledoc """
Autoban manager for handling automatic bans based on accumulated points.
Ported from: server.AutobanManager.java
This module:
- Accumulates anti-cheat points per account
- Triggers autoban when threshold is reached (5000 points)
- Handles point expiration over time
- Broadcasts ban notifications
- Tracks ban reasons
## Architecture
The AutobanManager is a singleton GenServer that:
- Stores points per account in its state
- Tracks expiration entries for automatic point decay
- Provides async ban operations
"""
use GenServer
require Logger
alias Odinsea.Database.Context
# Autoban threshold - 5000 points triggers automatic ban
@autoban_points 5000
# How often to check for point expiration (ms)
@expiration_check_interval 30_000
# =============================================================================
# Public API
# =============================================================================
@doc """
Starts the AutobanManager.
"""
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@doc """
Adds points to an account for a cheating offense.
"""
def add_points(account_id, points, expiration, reason) do
GenServer.call(__MODULE__, {:add_points, account_id, points, expiration, reason})
end
@doc """
Immediately autobans an account.
"""
def autoban(account_id, reason) do
# Add maximum points to trigger ban
add_points(account_id, @autoban_points, 0, reason)
end
@doc """
Gets current points for an account.
"""
def get_points(account_id) do
GenServer.call(__MODULE__, {:get_points, account_id})
end
@doc """
Gets ban reasons for an account.
"""
def get_reasons(account_id) do
GenServer.call(__MODULE__, {:get_reasons, account_id})
end
@doc """
Clears points for an account (e.g., after manual review).
"""
def clear_points(account_id) do
GenServer.call(__MODULE__, {:clear_points, account_id})
end
# =============================================================================
# GenServer Callbacks
# =============================================================================
@impl true
def init(_) do
# Schedule expiration checks
schedule_expiration_check()
{:ok, %{
# Map of account_id => current_points
points: %{},
# Map of account_id => [reasons]
reasons: %{},
# List of expiration entries: %{time: timestamp, account_id: id, points: points}
expirations: []
}}
end
@impl true
def handle_call({:add_points, account_id, points, expiration, reason}, _from, state) do
# Get current points
current_points = Map.get(state.points, account_id, 0)
# Check if already banned
if current_points >= @autoban_points do
{:reply, :already_banned, state}
else
# Add points
new_points = current_points + points
# Add reason
current_reasons = Map.get(state.reasons, account_id, [])
new_reasons = [reason | current_reasons]
# Update state
new_state = %{state |
points: Map.put(state.points, account_id, new_points),
reasons: Map.put(state.reasons, account_id, new_reasons)
}
# Add expiration entry if expiration > 0
new_state = if expiration > 0 do
expiration_time = System.system_time(:millisecond) + expiration
entry = %{time: expiration_time, account_id: account_id, points: points}
%{new_state | expirations: [entry | state.expirations]}
else
new_state
end
# Check if autoban threshold reached
new_state = if new_points >= @autoban_points do
execute_autoban(account_id, new_reasons, reason)
new_state
else
new_state
end
{:reply, :ok, new_state}
end
end
@impl true
def handle_call({:get_points, account_id}, _from, state) do
points = Map.get(state.points, account_id, 0)
{:reply, points, state}
end
@impl true
def handle_call({:get_reasons, account_id}, _from, state) do
reasons = Map.get(state.reasons, account_id, [])
{:reply, reasons, state}
end
@impl true
def handle_call({:clear_points, account_id}, _from, state) do
new_state = %{state |
points: Map.delete(state.points, account_id),
reasons: Map.delete(state.reasons, account_id),
expirations: Enum.reject(state.expirations, &(&1.account_id == account_id))
}
{:reply, :ok, new_state}
end
@impl true
def handle_info(:check_expirations, state) do
now = System.system_time(:millisecond)
# Process expirations that are due
{expired, remaining} = Enum.split_with(state.expirations, &(&1.time <= now))
# Decrement points for expired entries
new_points = Enum.reduce(expired, state.points, fn entry, acc ->
current = Map.get(acc, entry.account_id, 0)
new_amount = max(0, current - entry.points)
Map.put(acc, entry.account_id, new_amount)
end)
# Schedule next check
schedule_expiration_check()
{:noreply, %{state | points: new_points, expirations: remaining}}
end
@impl true
def handle_info(_msg, state) do
{:noreply, state}
end
# =============================================================================
# Private Functions
# =============================================================================
defp schedule_expiration_check do
Process.send_after(self(), :check_expirations, @expiration_check_interval)
end
defp execute_autoban(account_id, all_reasons, last_reason) do
Logger.warning("[AutobanManager] Executing autoban for account #{account_id}")
# Build ban reason
reason_string =
all_reasons
|> Enum.reverse()
|> Enum.join(", ")
full_reason = "Autoban: #{reason_string} (Last: #{last_reason})"
# Get character info if available
# Note: This is simplified - in production, you'd look up the active character
# Ban the account
case Context.ban_account(account_id, full_reason, false) do
{:ok, _} ->
Logger.info("[AutobanManager] Account #{account_id} banned successfully")
# Broadcast to all channels
broadcast_ban_notification(account_id, last_reason)
# Disconnect any active sessions
disconnect_sessions(account_id)
{:error, reason} ->
Logger.error("[AutobanManager] Failed to ban account #{account_id}: #{inspect(reason)}")
end
end
defp broadcast_ban_notification(account_id, reason) do
# Build notification message
message = "[Autoban] Account #{account_id} was banned (Reason: #{reason})"
# TODO: Broadcast to all channels
# This would typically go through the World service
Logger.info("[AutobanManager] Broadcast: #{message}")
# TODO: Send to Discord if configured
# DiscordClient.send_message_admin(message)
:ok
end
defp disconnect_sessions(account_id) do
# TODO: Find and disconnect all active sessions for this account
# This would look up sessions in the Client registry
Logger.info("[AutobanManager] Disconnecting sessions for account #{account_id}")
:ok
end
end

View File

@@ -0,0 +1,79 @@
defmodule Odinsea.AntiCheat.CheaterData do
@moduledoc """
Data structure for tracking cheaters.
Ported from: handling.world.CheaterData.java
Stores information about a cheating offense for reporting/broadcasting:
- points: The point value of the offense
- info: Description of the offense
"""
defstruct [:points, :info]
@type t :: %__MODULE__{
points: integer(),
info: String.t()
}
@doc """
Creates a new CheaterData entry.
"""
@spec new(integer(), String.t()) :: t()
def new(points, info) do
%__MODULE__{
points: points,
info: info
}
end
@doc """
Compares two CheaterData entries by points (descending order).
"""
@spec compare(t(), t()) :: :gt | :eq | :lt
def compare(%__MODULE__{points: p1}, %__MODULE__{points: p2}) do
cond do
p1 > p2 -> :gt
p1 == p2 -> :eq
true -> :lt
end
end
@doc """
Sorts a list of CheaterData by points (highest first).
"""
@spec sort_by_points(list(t())) :: list(t())
def sort_by_points(cheater_data_list) do
Enum.sort(cheater_data_list, fn a, b ->
compare(a, b) == :gt
end)
end
@doc """
Gets the top N cheaters by points.
"""
@spec top_cheaters(list(t()), integer()) :: list(t())
def top_cheaters(cheater_data_list, n) do
cheater_data_list
|> sort_by_points()
|> Enum.take(n)
end
@doc """
Calculates total points from a list of CheaterData.
"""
@spec total_points(list(t())) :: integer()
def total_points(cheater_data_list) do
Enum.reduce(cheater_data_list, 0, fn data, acc ->
acc + data.points
end)
end
@doc """
Formats CheaterData for display/logging.
"""
@spec format(t()) :: String.t()
def format(%__MODULE__{points: points, info: info}) do
"[#{points} pts] #{info}"
end
end

View File

@@ -0,0 +1,569 @@
defmodule Odinsea.AntiCheat.LieDetector do
@moduledoc """
Lie detector (Anti-Macro) system for bot detection.
Ported from: client.AntiMacro.java, tools.packet.AntiMacroPacket.java
The lie detector system:
- Sends a CAPTCHA image to the player
- Player has 60 seconds to respond
- If failed, player is punished (HP/MP to 0)
- If passed, reward can be given
## Response Types
- 0x00: Req_Fail_InvalidCharacterName
- 0x01: Req_Fail_NotAttack
- 0x02: Req_Fail_NotAvailableTime
- 0x03: Req_Fail_SolvingQuestion
- 0x04: Pended
- 0x05: Success
- 0x06: Res
- 0x07: Res_Fail
- 0x08: Res_TargetFail
- 0x09: Res_Success
- 0x0A: Res_TargetSuccess
- 0x0B: Res_Reward
## State
- `character_id`: The character being tested
- `in_progress`: Whether a test is currently running
- `passed`: Whether the player has passed
- `attempt`: Remaining attempts (-1 = failed)
- `answer`: The correct answer
- `tester`: Who initiated the test
- `type`: 0 = item, 1 = admin
- `last_time`: Timestamp of last test
"""
use GenServer
require Logger
alias Odinsea.Game.Character
alias Odinsea.World
@table :lie_detectors
# Test timeout in milliseconds (60 seconds)
@test_timeout 60_000
# Cooldown between tests (10 minutes)
@test_cooldown 600_000
# Reward for passing (meso)
@pass_reward 5000
# Punishment for failing (meso to tester)
@fail_reward_to_TESTER 7000
# =============================================================================
# Response Types
# =============================================================================
defmodule ResponseType do
@moduledoc "Lie detector response types"
def req_fail_invalid_character_name, do: 0x00
def req_fail_not_attack, do: 0x01
def req_fail_not_available_time, do: 0x02
def req_fail_solving_question, do: 0x03
def pended, do: 0x04
def success, do: 0x05
def res, do: 0x06
def res_fail, do: 0x07
def res_target_fail, do: 0x08
def res_success, do: 0x09
def res_target_success, do: 0x0A
def res_reward, do: 0x0B
end
# =============================================================================
# Public API
# =============================================================================
@doc """
Starts the lie detector system and creates ETS table.
"""
def start_system do
case :ets.info(@table) do
:undefined ->
:ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
:ok
_ ->
:ok
end
end
@doc """
Starts a lie detector session for a character.
"""
def start_session(character_id) do
start_system()
case lookup_session(character_id) do
nil ->
# Create new session
session = %{
character_id: character_id,
in_progress: false,
passed: false,
attempt: 1,
answer: nil,
tester: "",
type: 0,
last_time: 0,
timer_ref: nil
}
:ets.insert(@table, {character_id, session})
{:ok, session}
existing ->
{:ok, existing}
end
end
@doc """
Ends a lie detector session.
"""
def end_session(character_id) do
case lookup_session(character_id) do
nil -> :ok
session ->
# Cancel any pending timer
if session.timer_ref do
Process.cancel_timer(session.timer_ref)
end
:ets.delete(@table, character_id)
:ok
end
end
@doc """
Starts a lie detector test for a character.
Options:
- `tester`: Who initiated the test (default: "Admin")
- `is_item`: Whether started via item (default: false)
- `another_attempt`: Whether this is a retry (default: false)
"""
def start_test(character_id, opts \\ []) do
tester = Keyword.get(opts, :tester, "Admin")
is_item = Keyword.get(opts, :is_item, false)
another_attempt = Keyword.get(opts, :another_attempt, false)
# Ensure session exists
{:ok, session} = start_session(character_id)
# Check if can start test
cond do
not another_attempt and session.passed and is_item ->
{:error, :already_passed}
not another_attempt and session.in_progress ->
{:error, :already_in_progress}
not another_attempt and session.attempt == -1 ->
{:error, :already_failed}
true ->
do_start_test(character_id, tester, is_item, session)
end
end
@doc """
Validates a lie detector response from a player.
"""
def validate_response(character_id, response) do
case lookup_session(character_id) do
nil ->
{:error, :no_session}
session ->
if not session.in_progress do
{:error, :not_in_progress}
else
# Cancel timeout timer
if session.timer_ref do
Process.cancel_timer(session.timer_ref)
end
# Check answer
if String.upcase(response) == String.upcase(session.answer) do
# Correct!
handle_pass(character_id, session)
else
# Wrong!
handle_fail(character_id, session)
end
end
end
end
@doc """
Checks if a character can be tested (cooldown check).
"""
def can_test?(character_id) do
case lookup_session(character_id) do
nil -> true
session ->
now = System.system_time(:millisecond)
now > session.last_time + @test_cooldown
end
end
@doc """
Gets the current state of a lie detector session.
"""
def get_session(character_id) do
lookup_session(character_id)
end
@doc """
Admin command to start lie detector on a player.
"""
def admin_start_lie_detector(target_name, admin_name \\ "Admin") do
# Look up character by name
case World.find_character_by_name(target_name) do
nil ->
{:error, :character_not_found}
character ->
character_id = Map.get(character, :id)
start_test(character_id, tester: admin_name, is_item: false)
end
end
# =============================================================================
# Packet Builders
# =============================================================================
@doc """
Builds the lie detector packet with CAPTCHA image.
Ported from: AntiMacroPacket.sendLieDetector()
"""
def build_lie_detector_packet(image_data, attempts_remaining) do
# Opcode will be added by packet builder
packet = <<>>
# Response type: 0x06 (Res)
packet = packet <> <<ResponseType.res()>>
# Action: 4 (show CAPTCHA)
packet = packet <> <<4>>
# Attempts remaining
packet = packet <> <<attempts_remaining>>
# JPEG image data
packet = packet <> encode_jpeg(image_data)
packet
end
@doc """
Builds a lie detector response packet.
Ported from: AntiMacroPacket.LieDetectorResponse()
"""
def build_response_packet(msg_type, msg2 \\ 0) do
packet = <<>>
packet = packet <> <<msg_type>>
packet = packet <> <<msg2>>
packet
end
@doc """
Builds various lie detector message packets.
"""
def build_message_packet(type, opts \\ []) do
packet = <<>>
packet = packet <> <<type>>
case type do
4 -> # Save screenshot
packet = packet <> <<0>>
packet = packet <> encode_string(Keyword.get(opts, :filename, ""))
5 -> # Success with tester name
packet = packet <> <<1>>
packet = packet <> encode_string(Keyword.get(opts, :tester, ""))
6 -> # Admin picture
packet = packet <> <<4>>
packet = packet <> <<1>>
# Image data would go here
7 -> # Failed
packet = packet <> <<4>>
9 -> # Success/Passed
packet = packet <> <<Keyword.get(opts, :result, 0)>>
10 -> # Passed message
packet = packet <> <<0>>
packet = packet <> encode_string(Keyword.get(opts, :message, ""))
packet = packet <> encode_string("")
_ ->
packet = packet <> <<0>>
end
packet
end
# =============================================================================
# Private Functions
# =============================================================================
defp lookup_session(character_id) do
case :ets.lookup(@table, character_id) do
[{^character_id, session}] -> session
[] -> nil
end
end
defp update_session(character_id, updates) do
case lookup_session(character_id) do
nil -> :error
session ->
new_session = Map.merge(session, updates)
:ets.insert(@table, {character_id, new_session})
{:ok, new_session}
end
end
defp do_start_test(character_id, tester, is_item, session) do
# Generate CAPTCHA
{answer, image_data} = generate_captcha()
# Update session
new_attempt = session.attempt - 1
{:ok, new_session} = update_session(character_id, %{
in_progress: true,
passed: false,
attempt: new_attempt,
answer: answer,
tester: tester,
type: if(is_item, do: 0, else: 1),
last_time: System.system_time(:millisecond)
})
# Schedule timeout
timer_ref = Process.send_after(
self(),
{:lie_detector_timeout, character_id, is_item},
@test_timeout
)
update_session(character_id, %{timer_ref: timer_ref})
# Build packet
packet = build_lie_detector_packet(image_data, new_attempt + 1)
# Send to character
send_to_character(character_id, packet)
{:ok, new_session}
end
defp handle_pass(character_id, session) do
# Mark as passed
update_session(character_id, %{
in_progress: false,
passed: true,
attempt: 1,
last_time: System.system_time(:millisecond)
})
# Send success packet
packet = build_response_packet(ResponseType.res_success(), 0)
send_to_character(character_id, packet)
# Give reward if applicable
if session.type == 0 do
# Item-initiated, give reward
give_reward(character_id)
end
# Log
Logger.info("[LieDetector] Character #{character_id} passed the test")
:ok
end
defp handle_fail(character_id, session) do
if session.attempt == -1 do
# Out of attempts - execute punishment
execute_punishment(character_id, session)
else
# Try again
start_test(character_id,
tester: session.tester,
is_item: session.type == 0,
another_attempt: true
)
end
end
defp execute_punishment(character_id, session) do
# Update session
update_session(character_id, %{
in_progress: false,
passed: false,
attempt: -1
})
# Send fail packet
packet = build_response_packet(ResponseType.res_fail(), 0)
send_to_character(character_id, packet)
# Punish character (set HP/MP to 0)
punish_character(character_id)
# Reward tester if applicable
if session.tester != "" and session.tester != "Admin" do
reward_tester(session.tester, character_id)
end
# Broadcast to GMs
broadcast_gm_alert(character_id)
# Log
Logger.warning("[LieDetector] Character #{character_id} failed the test - punished")
:ok
end
defp punish_character(character_id) do
# Set HP and MP to 0
Character.set_hp(character_id, 0)
Character.set_mp(character_id, 0)
# Update stats
Character.update_single_stat(character_id, :hp, 0)
Character.update_single_stat(character_id, :mp, 0)
end
defp reward_tester(tester_name, failed_character_id) do
# Find tester
case World.find_character_by_name(tester_name) do
nil ->
:ok
tester ->
# Give meso reward
Character.gain_meso(Map.get(tester, :id), @fail_reward_to_TESTER, true)
# Send message
msg = "#{failed_character_id} did not pass the lie detector test. You received #{@fail_reward_to_TESTER} meso."
Character.drop_message(Map.get(tester, :id), 5, msg)
end
end
defp give_reward(character_id) do
# Give reward for passing
Character.gain_meso(character_id, @pass_reward, true)
# Send reward packet
packet = build_response_packet(ResponseType.res_reward(), 1)
send_to_character(character_id, packet)
end
defp send_to_character(character_id, packet) do
# This would send the packet through the character's client connection
# Implementation depends on the channel client system
:ok
end
defp broadcast_gm_alert(character_id) do
# TODO: Broadcast to GMs through World service
Logger.info("[LieDetector] GM Alert: Character #{character_id} failed lie detector")
:ok
end
defp generate_captcha do
# Generate a simple text CAPTCHA
# In production, this would generate an image
# Random 4-6 character alphanumeric code
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
length = Enum.random(4..6)
answer =
1..length
|> Enum.map(fn _ -> String.at(chars, Enum.random(0..(String.length(chars) - 1))) end)
|> Enum.join()
# For now, return a placeholder image
# In production, this would generate a JPEG image with the text
image_data = generate_captcha_image(answer)
{answer, image_data}
end
defp generate_captcha_image(answer) do
# Placeholder - in production, this would use an image generation library
# or pre-generated CAPTCHA images
# Return a simple binary representation
# Real implementation would use something like:
# - Mogrify (ImageMagick wrapper)
# - Imagine (Elixir image library)
# - Pre-generated CAPTCHA images stored in priv/
<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
String.length(answer)::32, answer::binary>>
end
defp encode_string(str) do
len = String.length(str)
<<len::16-little, str::binary>>
end
defp encode_jpeg(data) do
# Prepend length and data
len = byte_size(data)
<<len::32-little, data::binary>>
end
# =============================================================================
# GenServer for timeout handling
# =============================================================================
defmodule TimeoutHandler do
@moduledoc "Handles lie detector timeouts"
use GenServer
alias Odinsea.AntiCheat.LieDetector
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, %{}}
end
def handle_info({:lie_detector_timeout, character_id, is_item}, state) do
# Timeout occurred - treat as failure
case LieDetector.lookup_session(character_id) do
nil ->
{:noreply, state}
session ->
if session.in_progress do
# Execute timeout punishment
LieDetector.execute_punishment(character_id, session)
end
{:noreply, state}
end
end
end
end

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

View File

@@ -0,0 +1,36 @@
defmodule Odinsea.AntiCheat.Supervisor do
@moduledoc """
Supervisor for the Anti-Cheat system.
Manages:
- AutobanManager (singleton)
- CheatTracker processes (dynamic)
- LieDetector timeout handler
"""
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = [
# Autoban manager (singleton)
Odinsea.AntiCheat.AutobanManager,
# Lie detector timeout handler
Odinsea.AntiCheat.LieDetector.TimeoutHandler,
# Dynamic supervisor for per-character cheat trackers
{DynamicSupervisor,
name: Odinsea.CheatTrackerSupervisor,
strategy: :one_for_one,
max_restarts: 1000,
max_seconds: 60}
]
Supervisor.init(children, strategy: :one_for_all)
end
end

View File

@@ -0,0 +1,438 @@
defmodule Odinsea.AntiCheat.Validator do
@moduledoc """
Validation functions for anti-cheat detection.
Ported from: handling.channel.handler.DamageParse.java
This module provides validation for:
- Damage validation (checking against calculated max damage)
- Movement validation (speed hacking detection)
- Item validation (dupe detection, unavailable items)
- EXP validation (leveling too fast)
- Attack validation (skill timing, bullet count)
"""
require Logger
alias Odinsea.AntiCheat.CheatTracker
alias Odinsea.Game.Character
alias Odinsea.Constants.Game
# Maximum damage cap (from Plugin.java DamageCap)
@damage_cap 9_999_999
# Maximum distance for attacking (squared, for distance check)
@max_attack_distance_sq 500_000
# Maximum movement speed
@max_movement_speed 400
# Maximum jump height
@max_jump_height 200
# =============================================================================
# Damage Validation
# =============================================================================
@doc """
Validates damage dealt to a monster.
Returns {:ok, validated_damage} or {:error, reason}
"""
def validate_damage(character_id, damage, expected_max, monster_id, skill_id) do
# Check if damage exceeds expected max
state = %{character_id: character_id}
# Check for high damage
state = if damage > expected_max do
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
CheatTracker.register_offense(character_id, :high_damage, param)
# Check for same damage (potential damage hack)
CheatTracker.check_same_damage(character_id, damage, expected_max)
state
else
state
end
# Check for damage exceeding 2x expected (HIGH_DAMAGE_2)
state = if damage > expected_max * 2 do
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
CheatTracker.register_offense(character_id, :high_damage_2, param)
# Cap the damage
capped_damage = trunc(expected_max * 2)
{:ok, capped_damage}
else
{:ok, damage}
end
# Check against global damage cap
state = if damage > @damage_cap do
CheatTracker.register_offense(character_id, :exceed_damage_cap,
"Damage: #{damage}, Cap: #{@damage_cap}")
{:ok, @damage_cap}
else
state
end
state
end
@doc """
Validates magic damage dealt to a monster.
"""
def validate_magic_damage(character_id, damage, expected_max, monster_id, skill_id) do
# Check for high magic damage
if damage > expected_max do
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
CheatTracker.register_offense(character_id, :high_damage_magic, param)
# Check for same damage
CheatTracker.check_same_damage(character_id, damage, expected_max)
end
# Check for damage exceeding 2x expected (HIGH_DAMAGE_MAGIC_2)
if damage > expected_max * 2 do
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
CheatTracker.register_offense(character_id, :high_damage_magic_2, param)
# Cap the damage
{:ok, trunc(expected_max * 2)}
else
{:ok, damage}
end
end
@doc """
Calculates maximum weapon damage per hit for validation.
Ported from: DamageParse.CalculateMaxWeaponDamagePerHit()
"""
def calculate_max_weapon_damage(character, monster, attack_skill) do
# Base damage calculation
base_damage = Character.get_stat(character, :max_base_damage) || 100
# Apply skill multipliers
damage = if attack_skill && attack_skill > 0 do
skill_damage = Game.get_skill_damage(attack_skill)
base_damage * (skill_damage / 100.0)
else
base_damage
end
# Apply monster defense
# pdr_rate = Map.get(monster, :pdr_rate, 0)
# damage = damage * (1 - pdr_rate / 100.0)
# Apply boss damage modifier if monster is boss
# damage = if Map.get(monster, :is_boss, false) do
# boss_dam_r = Character.get_stat(character, :bossdam_r) || 0
# damage * (1 + boss_dam_r / 100.0)
# else
# damage
# end
# Apply damage rate
# dam_r = Character.get_stat(character, :dam_r) || 100
# damage = damage * (dam_r / 100.0)
trunc(max(damage, 1))
end
@doc """
Calculates maximum magic damage per hit for validation.
Ported from: DamageParse.CalculateMaxMagicDamagePerHit()
"""
def calculate_max_magic_damage(character, monster, attack_skill) do
# Base magic damage calculation
base_damage = Character.get_stat(character, :max_base_damage) || 100
# Magic has different multipliers
damage = if attack_skill && attack_skill > 0 do
skill_damage = Game.get_skill_damage(attack_skill)
base_damage * (skill_damage / 100.0) * 1.5
else
base_damage * 1.5
end
# Apply monster magic defense
# mdr_rate = Map.get(monster, :mdr_rate, 0)
# damage = damage * (1 - mdr_rate / 100.0)
trunc(max(damage, 1))
end
@doc """
Checks if attack is at valid range.
"""
def validate_attack_range(character_id, attacker_pos, target_pos, skill_id) do
# Calculate distance
distance_sq = calculate_distance_sq(attacker_pos, target_pos)
# Get expected range for skill
expected_range = Game.get_attack_range(skill_id)
if distance_sq > expected_range * expected_range do
param = "Distance: #{distance_sq}, Expected: #{expected_range * expected_range}, Skill: #{skill_id}"
CheatTracker.register_offense(character_id, :attack_faraway_monster, param)
{:error, :out_of_range}
else
:ok
end
end
# =============================================================================
# Attack Validation
# =============================================================================
@doc """
Validates attack count matches skill expectations.
"""
def validate_attack_count(character_id, skill_id, hits, targets, expected_hits, expected_targets) do
# Skip certain skills that have special handling
if skill_id in [4211006, 3221007, 23121003, 1311001] do
:ok
else
# Check hits
if hits > expected_hits do
CheatTracker.register_offense(character_id, :mismatching_bulletcount,
"Hits: #{hits}, Expected: #{expected_hits}")
{:error, :invalid_hits}
else
# Check targets
if targets > expected_targets do
CheatTracker.register_offense(character_id, :mismatching_bulletcount,
"Targets: #{targets}, Expected: #{expected_targets}")
{:error, :invalid_targets}
else
:ok
end
end
end
end
@doc """
Validates the character is alive before attacking.
"""
def validate_alive(character_id, is_alive) do
if not is_alive do
CheatTracker.register_offense(character_id, :attacking_while_dead, nil)
{:error, :dead}
else
:ok
end
end
@doc """
Validates skill usage in specific maps (e.g., Mu Lung, Pyramid).
"""
def validate_skill_map(character_id, skill_id, map_id) do
# Check Mu Lung skills
if Game.is_mulung_skill?(skill_id) do
if div(map_id, 10000) != 92502 do
# Using Mu Lung skill outside dojo
{:error, :wrong_map}
else
:ok
end
else
# Check Pyramid skills
if Game.is_pyramid_skill?(skill_id) do
if div(map_id, 1000000) != 926 do
# Using Pyramid skill outside pyramid
{:error, :wrong_map}
else
:ok
end
else
:ok
end
end
end
# =============================================================================
# Movement Validation
# =============================================================================
@doc """
Validates player movement for speed hacking.
Returns :ok if valid, or {:error, reason} if suspicious.
"""
def validate_movement(character_id, old_pos, new_pos, time_diff_ms) do
# Calculate distance
distance = calculate_distance(old_pos, new_pos)
# Calculate speed
if time_diff_ms > 0 do
speed = distance / (time_diff_ms / 1000.0)
# Check if speed exceeds maximum
if speed > @max_movement_speed do
# Could be speed hacking or lag
# Only flag if significantly over
if speed > @max_movement_speed * 1.5 do
Logger.warning("[AntiCheat] Speed hack suspected for char #{character_id}: #{speed} px/s")
# TODO: Add to offense tracking when FAST_MOVE offense is enabled
{:error, :speed_exceeded}
else
:ok
end
else
:ok
end
else
# Instant movement - check distance
if distance > @max_movement_speed do
{:error, :teleport_detected}
else
:ok
end
end
end
@doc """
Validates jump height for high jump detection.
"""
def validate_jump(character_id, y_delta) do
# Check if jump exceeds maximum
if y_delta < -@max_jump_height do
CheatTracker.register_offense(character_id, :high_jump,
"Jump: #{y_delta}, Max: #{@max_jump_height}")
{:error, :high_jump}
else
:ok
end
end
@doc """
Validates portal usage distance.
"""
def validate_portal_distance(character_id, player_pos, portal_pos) do
distance_sq = calculate_distance_sq(player_pos, portal_pos)
max_portal_distance_sq = 200 * 200 # 200 pixels
if distance_sq > max_portal_distance_sq do
CheatTracker.register_offense(character_id, :using_faraway_portal,
"Distance: #{:math.sqrt(distance_sq)}")
{:error, :too_far}
else
:ok
end
end
# =============================================================================
# Item Validation
# =============================================================================
@doc """
Validates item usage (checks if item is available to character).
"""
def validate_item_usage(character_id, item_id, inventory) do
# Check if item exists in inventory
has_item = Enum.any?(inventory, fn item ->
Map.get(item, :item_id) == item_id
end)
if not has_item do
CheatTracker.register_offense(character_id, :using_unavailable_item,
"ItemID: #{item_id}")
{:error, :item_not_found}
else
:ok
end
end
@doc """
Validates item quantity (dupe detection).
"""
def validate_item_quantity(character_id, item_id, quantity, expected_max) do
if quantity > expected_max do
# Potential dupe
Logger.warning("[AntiCheat] Suspicious item quantity for char #{character_id}: #{item_id} x#{quantity}")
{:error, :quantity_exceeded}
else
:ok
end
end
@doc """
Validates meso explosion (checks if meso exists on map).
"""
def validate_meso_explosion(character_id, map_item) do
if map_item == nil do
CheatTracker.register_offense(character_id, :exploding_nonexistant, nil)
{:error, :no_meso}
else
meso = Map.get(map_item, :meso, 0)
if meso <= 0 do
CheatTracker.register_offense(character_id, :etc_explosion, nil)
{:error, :invalid_meso}
else
:ok
end
end
end
# =============================================================================
# EXP Validation
# =============================================================================
@doc """
Validates EXP gain rate.
"""
def validate_exp_gain(character_id, exp_gained, time_since_last_gain_ms) do
# Calculate EXP per minute
if time_since_last_gain_ms > 0 do
exp_per_minute = exp_gained / (time_since_last_gain_ms / 60000.0)
# Maximum reasonable EXP per minute (varies by level, this is a rough check)
max_exp_per_minute = 10_000_000
if exp_per_minute > max_exp_per_minute do
Logger.warning("[AntiCheat] High EXP rate for char #{character_id}: #{exp_per_minute}/min")
# TODO: Add to offense tracking
{:warning, :high_exp_rate}
else
:ok
end
else
:ok
end
end
@doc """
Validates level progression (checks for impossible jumps).
"""
def validate_level_progression(old_level, new_level) do
max_level_jump = 5
if new_level - old_level > max_level_jump do
{:error, :impossible_level_jump}
else
:ok
end
end
# =============================================================================
# Helper Functions
# =============================================================================
defp build_damage_param(damage, expected, monster_id, skill_id, character_id) do
"[Damage: #{damage}, Expected: #{expected}, Mob: #{monster_id}] [Skill: #{skill_id}]"
end
defp calculate_distance_sq(pos1, pos2) do
dx = Map.get(pos1, :x, 0) - Map.get(pos2, :x, 0)
dy = Map.get(pos1, :y, 0) - Map.get(pos2, :y, 0)
dx * dx + dy * dy
end
defp calculate_distance(pos1, pos2) do
:math.sqrt(calculate_distance_sq(pos1, pos2))
end
end