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