defmodule Odinsea.Game.Movement do @moduledoc """ Movement parsing and validation for players, mobs, pets, summons, and dragons. Ported from Java MovementParse.java and all movement type classes. Movement types (kind): - 1: Player - 2: Mob - 3: Pet - 4: Summon - 5: Dragon - 6: Familiar Movement command types (40+ types): - 0, 37-42: Absolute movement (normal walking, flying) - 1, 2, 33, 34, 36: Relative movement (small adjustments) - 3, 4, 8, 100, 101: Teleport movement (rush, assassinate) - 5-7, 16-20: Mixed (teleport, aran, relative, bounce) - 9, 12: Chair movement - 10, 11: Stat change / equip special - 13, 14: Jump down (fall through platforms) - 15: Float (GMS vs non-GMS difference) - 21-31, 35: Aran combat step - 25-31: Special aran movements - 32: Unknown movement - -1: Bounce movement Anti-cheat features: - Speed hack detection - High jump detection - Teleport validation - Movement count validation """ require Logger alias Odinsea.Net.Packet.In alias Odinsea.Game.Movement.{Absolute, Relative, Teleport, JumpDown, Aran, Chair, Bounce, ChangeEquip, Unknown} # Movement kind constants @kind_player 1 @kind_mob 2 @kind_pet 3 @kind_summon 4 @kind_dragon 5 @kind_familiar 6 @kind_android 7 # GMS flag - affects movement parsing @gms Application.compile_env(:odinsea, :gms, true) @doc """ Parses movement data from a packet. Returns {:ok, movements} or {:error, reason}. ## Examples iex> Movement.parse_movement(packet, 1) # Player movement {:ok, [%Absolute{command: 0, x: 100, y: 200, ...}, ...]} """ def parse_movement(packet, kind) do num_commands = In.decode_byte(packet) case parse_commands(packet, kind, num_commands, []) do {:ok, movements} when length(movements) == num_commands -> {:ok, Enum.reverse(movements)} {:ok, _movements} -> {:error, :command_count_mismatch} {:error, reason} -> {:error, reason} end rescue e -> Logger.warning("Movement parse error: #{inspect(e)}") {:error, :parse_exception} end @doc """ Updates an entity's position from movement data. Returns the final position and stance. """ def update_position(movements, current_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do Enum.reduce(movements, {current_position, nil}, fn movement, {pos, _last_move} -> new_pos = extract_position(movement, pos) {new_pos, movement} end) |> elem(0) end @doc """ Validates movement for anti-cheat purposes. Returns {:ok, validated_movements} or {:error, reason}. """ def validate_movement(movements, entity_type, options \\ []) do max_commands = Keyword.get(options, :max_commands, 100) max_distance = Keyword.get(options, :max_distance, 2000) start_pos = Keyword.get(options, :start_position, %{x: 0, y: 0}) cond do length(movements) > max_commands -> {:error, :too_many_commands} length(movements) == 0 -> {:error, :no_movement} exceeds_max_distance?(movements, start_pos, max_distance) -> {:error, :suspicious_distance} contains_invalid_teleport?(movements, entity_type) -> {:error, :invalid_teleport} true -> {:ok, movements} end end @doc """ Serializes a list of movements for packet output. """ def serialize_movements(movements) when is_list(movements) do count = length(movements) data = Enum.map_join(movements, &serialize/1) <> end @doc """ Serializes a single movement fragment. """ def serialize(%Absolute{} = m) do <> end def serialize(%Relative{} = m) do <> end def serialize(%Teleport{} = m) do <> end def serialize(%JumpDown{} = m) do <> end def serialize(%Aran{} = m) do <> end def serialize(%Chair{} = m) do <> end def serialize(%Bounce{} = m) do <> end def serialize(%ChangeEquip{} = m) do <> end def serialize(%Unknown{} = m) do <> end # ============================================================================ # Private Functions # ============================================================================ defp parse_commands(_packet, _kind, 0, acc), do: {:ok, acc} defp parse_commands(packet, kind, remaining, acc) when remaining > 0 do command = In.decode_byte(packet) case parse_command(packet, kind, command) do {:ok, movement} -> parse_commands(packet, kind, remaining - 1, [movement | acc]) {:error, reason} -> {:error, reason} end end # Bounce movement (-1) defp parse_command(packet, _kind, -1) do x = In.decode_short(packet) y = In.decode_short(packet) unk = In.decode_short(packet) fh = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Bounce{ command: -1, x: x, y: y, unk: unk, foothold: fh, stance: stance, duration: duration }} end # Absolute movement (0, 37-42) - Normal walk/fly defp parse_command(packet, _kind, command) when command in [0, 37, 38, 39, 40, 41, 42] do x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) unk = In.decode_short(packet) offset_x = In.decode_short(packet) offset_y = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Absolute{ command: command, x: x, y: y, vx: vx, vy: vy, unk: unk, offset_x: offset_x, offset_y: offset_y, stance: stance, duration: duration }} end # Relative movement (1, 2, 33, 34, 36) - Small adjustments defp parse_command(packet, _kind, command) when command in [1, 2, 33, 34, 36] do xmod = In.decode_short(packet) ymod = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Relative{ command: command, x: xmod, y: ymod, stance: stance, duration: duration }} end # Teleport movement (3, 4, 8, 100, 101) - Rush, assassinate, etc. defp parse_command(packet, _kind, command) when command in [3, 4, 8, 100, 101] do x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) stance = In.decode_byte(packet) {:ok, %Teleport{ command: command, x: x, y: y, vx: vx, vy: vy, stance: stance }} end # Complex cases 5-7, 16-20 with GMS/non-GMS differences defp parse_command(packet, _kind, command) when command in [5, 6, 7, 16, 17, 18, 19, 20] do cond do # Bounce movement variants (@gms && command == 19) || (!@gms && command == 18) -> x = In.decode_short(packet) y = In.decode_short(packet) unk = In.decode_short(packet) fh = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Bounce{ command: command, x: x, y: y, unk: unk, foothold: fh, stance: stance, duration: duration }} # Aran movement (@gms && command == 17) || (!@gms && command == 16) || (!@gms && command == 20) -> stance = In.decode_byte(packet) unk = In.decode_short(packet) {:ok, %Aran{ command: command, stance: stance, unk: unk }} # Relative movement (@gms && command == 20) || (!@gms && command == 19) || (@gms && command == 18) || (!@gms && command == 17) -> xmod = In.decode_short(packet) ymod = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Relative{ command: command, x: xmod, y: ymod, stance: stance, duration: duration }} # Teleport movement variants (!@gms && command == 5) || (!@gms && command == 7) || (@gms && command == 6) -> x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) stance = In.decode_byte(packet) {:ok, %Teleport{ command: command, x: x, y: y, vx: vx, vy: vy, stance: stance }} # Default to absolute movement true -> x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) unk = In.decode_short(packet) offset_x = In.decode_short(packet) offset_y = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Absolute{ command: command, x: x, y: y, vx: vx, vy: vy, unk: unk, offset_x: offset_x, offset_y: offset_y, stance: stance, duration: duration }} end end # Chair movement (9, 12) defp parse_command(packet, _kind, command) when command in [9, 12] do x = In.decode_short(packet) y = In.decode_short(packet) unk = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Chair{ command: command, x: x, y: y, unk: unk, stance: stance, duration: duration }} end # Chair (10, 11) - GMS vs non-GMS differences defp parse_command(packet, _kind, command) when command in [10, 11] do if (@gms && command == 10) || (!@gms && command == 11) do x = In.decode_short(packet) y = In.decode_short(packet) unk = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Chair{ command: command, x: x, y: y, unk: unk, stance: stance, duration: duration }} else wui = In.decode_byte(packet) {:ok, %ChangeEquip{ command: command, wui: wui }} end end # Aran combat step (21-31, 35) defp parse_command(packet, _kind, command) when command in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] do stance = In.decode_byte(packet) unk = In.decode_short(packet) {:ok, %Aran{ command: command, stance: stance, unk: unk }} end # Jump down (13, 14) - with GMS/non-GMS differences defp parse_command(packet, _kind, command) when command in [13, 14] do cond do # Full jump down movement (@gms && command == 14) || (!@gms && command == 13) -> x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) unk = In.decode_short(packet) fh = In.decode_short(packet) offset_x = In.decode_short(packet) offset_y = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %JumpDown{ command: command, x: x, y: y, vx: vx, vy: vy, unk: unk, foothold: fh, offset_x: offset_x, offset_y: offset_y, stance: stance, duration: duration }} # GMS chair movement @gms && command == 13 -> x = In.decode_short(packet) y = In.decode_short(packet) unk = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Chair{ command: command, x: x, y: y, unk: unk, stance: stance, duration: duration }} # Default to relative true -> xmod = In.decode_short(packet) ymod = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Relative{ command: command, x: xmod, y: ymod, stance: stance, duration: duration }} end end # Float (15) - GMS vs non-GMS defp parse_command(packet, _kind, 15) do if @gms do xmod = In.decode_short(packet) ymod = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Relative{ command: 15, x: xmod, y: ymod, stance: stance, duration: duration }} else x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) unk = In.decode_short(packet) offset_x = In.decode_short(packet) offset_y = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Absolute{ command: 15, x: x, y: y, vx: vx, vy: vy, unk: unk, offset_x: offset_x, offset_y: offset_y, stance: stance, duration: duration }} end end # Unknown movement (32) defp parse_command(packet, _kind, 32) do unk = In.decode_short(packet) x = In.decode_short(packet) y = In.decode_short(packet) vx = In.decode_short(packet) vy = In.decode_short(packet) fh = In.decode_short(packet) stance = In.decode_byte(packet) duration = In.decode_short(packet) {:ok, %Unknown{ command: 32, unk: unk, x: x, y: y, vx: vx, vy: vy, foothold: fh, stance: stance, duration: duration }} end # Unknown command type defp parse_command(_packet, kind, command) do Logger.warning("Unknown movement command: kind=#{kind}, command=#{command}") {:error, {:unknown_command, command}} end # Extract position from different movement types defp extract_position(%{x: x, y: y, stance: stance} = movement, _current_pos) do fh = Map.get(movement, :foothold, 0) %{x: x, y: y, stance: stance, foothold: fh} end defp extract_position(_movement, current_pos), do: current_pos # Anti-cheat validation helpers defp exceeds_max_distance?(movements, start_pos, max_distance) do final_pos = update_position(movements, start_pos) dx = final_pos.x - start_pos.x dy = final_pos.y - start_pos.y distance_sq = dx * dx + dy * dy distance_sq > max_distance * max_distance end defp contains_invalid_teleport?(movements, entity_type) do # Only certain entities should be able to teleport allowed_teleport = entity_type in [:player, :mob] if allowed_teleport do # Check for suspicious teleport patterns teleport_count = Enum.count(movements, fn m -> is_struct(m, Teleport) end) # Too many teleports in one movement packet is suspicious teleport_count > 5 else # Non-allowed entities shouldn't teleport at all Enum.any?(movements, fn m -> is_struct(m, Teleport) end) end end # ============================================================================ # Public API for Handler Integration # ============================================================================ @doc """ Parses and validates player movement. Returns {:ok, movements, final_position} or {:error, reason}. """ def parse_player_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_player), {:ok, validated} <- validate_movement(movements, :player, start_position: start_position), final_pos <- update_position(validated, start_position) do {:ok, validated, final_pos} end end @doc """ Parses and validates mob movement. """ def parse_mob_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_mob), {:ok, validated} <- validate_movement(movements, :mob, start_position: start_position), final_pos <- update_position(validated, start_position) do {:ok, validated, final_pos} end end @doc """ Parses and validates pet movement. """ def parse_pet_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_pet), final_pos <- update_position(movements, start_position) do {:ok, movements, final_pos} end end @doc """ Parses and validates summon movement. """ def parse_summon_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_summon), final_pos <- update_position(movements, start_position) do {:ok, movements, final_pos} end end @doc """ Parses and validates familiar movement. """ def parse_familiar_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_familiar), final_pos <- update_position(movements, start_position) do {:ok, movements, final_pos} end end @doc """ Parses and validates android movement. """ def parse_android_movement(packet, start_position \\ %{x: 0, y: 0, stance: 0, foothold: 0}) do with {:ok, movements} <- parse_movement(packet, @kind_android), final_pos <- update_position(movements, start_position) do {:ok, movements, final_pos} end end @doc """ Returns the kind value for an entity type atom. """ def kind_for(:player), do: @kind_player def kind_for(:mob), do: @kind_mob def kind_for(:pet), do: @kind_pet def kind_for(:summon), do: @kind_summon def kind_for(:dragon), do: @kind_dragon def kind_for(:familiar), do: @kind_familiar def kind_for(:android), do: @kind_android def kind_for(_), do: @kind_player end