570 lines
15 KiB
Elixir
570 lines
15 KiB
Elixir
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
|