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