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 <> <> # Action: 4 (show CAPTCHA) packet = packet <> <<4>> # Attempts remaining packet = packet <> <> # 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 <> <> packet = packet <> <> packet end @doc """ Builds various lie detector message packets. """ def build_message_packet(type, opts \\ []) do packet = <<>> packet = packet <> <> 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 <> <> 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) <> end defp encode_jpeg(data) do # Prepend length and data len = byte_size(data) <> 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