251 lines
7.2 KiB
Elixir
251 lines
7.2 KiB
Elixir
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
|