698 lines
19 KiB
Elixir
698 lines
19 KiB
Elixir
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)
|
|
<<count::8, data::binary>>
|
|
end
|
|
|
|
@doc """
|
|
Serializes a single movement fragment.
|
|
"""
|
|
def serialize(%Absolute{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.vx::16-little, m.vy::16-little,
|
|
m.unk::16-little,
|
|
m.offset_x::16-little, m.offset_y::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
end
|
|
|
|
def serialize(%Relative{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
end
|
|
|
|
def serialize(%Teleport{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.vx::16-little, m.vy::16-little,
|
|
m.stance::8>>
|
|
end
|
|
|
|
def serialize(%JumpDown{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.vx::16-little, m.vy::16-little,
|
|
m.unk::16-little,
|
|
m.foothold::16-little,
|
|
m.offset_x::16-little, m.offset_y::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
end
|
|
|
|
def serialize(%Aran{} = m) do
|
|
<<m.command::8,
|
|
m.stance::8,
|
|
m.unk::16-little>>
|
|
end
|
|
|
|
def serialize(%Chair{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.unk::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
end
|
|
|
|
def serialize(%Bounce{} = m) do
|
|
<<m.command::8,
|
|
m.x::16-little, m.y::16-little,
|
|
m.unk::16-little,
|
|
m.foothold::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
end
|
|
|
|
def serialize(%ChangeEquip{} = m) do
|
|
<<m.command::8,
|
|
m.wui::8>>
|
|
end
|
|
|
|
def serialize(%Unknown{} = m) do
|
|
<<m.command::8,
|
|
m.unk::16-little,
|
|
m.x::16-little, m.y::16-little,
|
|
m.vx::16-little, m.vy::16-little,
|
|
m.foothold::16-little,
|
|
m.stance::8,
|
|
m.duration::16-little>>
|
|
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
|