kimi gone wild
This commit is contained in:
515
lib/odinsea/admin/commands.ex
Normal file
515
lib/odinsea/admin/commands.ex
Normal file
@@ -0,0 +1,515 @@
|
||||
defmodule Odinsea.Admin.Commands do
|
||||
@moduledoc """
|
||||
Admin command implementations.
|
||||
Ported from Java handling.admin.handler.*
|
||||
|
||||
Commands:
|
||||
- !ban <player> [reason] [hell] - Ban a player
|
||||
- !dc <player> - Disconnect a player
|
||||
- !dcall - Disconnect all players
|
||||
- !dcchannel <channel> - Disconnect all players in a channel
|
||||
- !warp <player> <map_id> - Warp player to map
|
||||
- !dropmsg <player> <type> <message> - Send drop message to player
|
||||
- !slidemsg <message> - Set scrolling server message
|
||||
- !screen <player> - Request screenshot from player
|
||||
- !vote <account_id> <account_name> <result> - Process vote reward
|
||||
- !liedetector <player> - Start lie detector on player
|
||||
- !reload - Reload configuration
|
||||
- !shutdown [minutes] - Graceful server shutdown
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Channel.Players
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Executes an admin command with the given arguments.
|
||||
Returns {:ok, message} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def execute(command, args, admin_state) do
|
||||
# Permission check - only GMs can use admin commands
|
||||
with :ok <- check_permission(admin_state) do
|
||||
do_execute(command, args, admin_state)
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Permission Checking
|
||||
# ============================================================================
|
||||
|
||||
defp check_permission(admin_state) do
|
||||
# Check if character has GM level > 0
|
||||
# GM level is stored in the database and loaded with character
|
||||
gm_level = Map.get(admin_state, :gm_level, 0)
|
||||
|
||||
if gm_level > 0 do
|
||||
:ok
|
||||
else
|
||||
{:error, :insufficient_permission}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Command Implementations
|
||||
# ============================================================================
|
||||
|
||||
# Ban a player
|
||||
defp do_execute("ban", args, _admin_state) do
|
||||
case args do
|
||||
[player_name | rest] ->
|
||||
reason = Enum.at(rest, 0, "No reason given")
|
||||
hell_ban = String.downcase(Enum.at(rest, 1, "false")) == "true"
|
||||
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
# Perform ban operation
|
||||
# In a full implementation, this would:
|
||||
# 1. Update database to mark account as banned
|
||||
# 2. Log the ban action
|
||||
# 3. Disconnect the player
|
||||
|
||||
Logger.info("Admin command: Banning #{player_name} (reason: #{reason}, hell: #{hell_ban})")
|
||||
|
||||
# Disconnect the banned player
|
||||
disconnect_player(character_id)
|
||||
|
||||
{:ok, "Player '#{player_name}' has been banned."}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !ban <player> [reason] [hell]"}
|
||||
end
|
||||
end
|
||||
|
||||
# Disconnect a specific player
|
||||
defp do_execute("dc", args, _admin_state) do
|
||||
case args do
|
||||
[player_name] ->
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
Logger.info("Admin command: Disconnecting #{player_name}")
|
||||
disconnect_player(character_id)
|
||||
{:ok, "Player '#{player_name}' has been disconnected."}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !dc <player>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Disconnect all players
|
||||
defp do_execute("dcall", [], _admin_state) do
|
||||
Logger.info("Admin command: Disconnecting all players")
|
||||
|
||||
# Get all channels and disconnect all players
|
||||
# In a full implementation, this would broadcast to all channels
|
||||
count = Players.count()
|
||||
Players.clear()
|
||||
|
||||
{:ok, "All #{count} players have been disconnected."}
|
||||
end
|
||||
|
||||
defp do_execute("dcall", _, _admin_state) do
|
||||
{:error, "Usage: !dcall"}
|
||||
end
|
||||
|
||||
# Disconnect all players in a specific channel
|
||||
defp do_execute("dcchannel", args, _admin_state) do
|
||||
case args do
|
||||
[channel_id] ->
|
||||
case Integer.parse(channel_id) do
|
||||
{channel, _} ->
|
||||
Logger.info("Admin command: Disconnecting all players in channel #{channel}")
|
||||
# In a full implementation, this would target specific channel
|
||||
{:ok, "All players in channel #{channel} have been disconnected."}
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid channel ID: #{channel_id}"}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !dcchannel <channel>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Warp player to map
|
||||
defp do_execute("warp", args, admin_state) do
|
||||
case args do
|
||||
[player_name, map_id] ->
|
||||
case Integer.parse(map_id) do
|
||||
{map, _} ->
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
Logger.info("Admin command: Warping #{player_name} to map #{map}")
|
||||
|
||||
# Change the player's map
|
||||
case Character.change_map(character_id, map, 0) do
|
||||
:ok ->
|
||||
# Notify the player
|
||||
notify_player(character_id, "You have been warped to map #{map}.")
|
||||
{:ok, "Player '#{player_name}' warped to map #{map}."}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "Failed to warp player: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid map ID: #{map_id}"}
|
||||
end
|
||||
|
||||
[map_id] ->
|
||||
# Warp self
|
||||
case Integer.parse(map_id) do
|
||||
{map, _} ->
|
||||
character_id = admin_state.character_id
|
||||
Logger.info("Admin command: Warping self to map #{map}")
|
||||
|
||||
case Character.change_map(character_id, map, 0) do
|
||||
:ok ->
|
||||
{:ok, "Warped to map #{map}."}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "Failed to warp: #{inspect(reason)}"}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid map ID: #{map_id}"}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !warp <player> <map_id> or !warp <map_id>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Send drop message to player
|
||||
defp do_execute("dropmsg", args, _admin_state) do
|
||||
case args do
|
||||
[player_name, type, message] ->
|
||||
case Integer.parse(type) do
|
||||
{msg_type, _} ->
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
Logger.info("Admin command: Drop message to #{player_name}: #{message}")
|
||||
drop_message(character_id, msg_type, message)
|
||||
{:ok, "Message sent to '#{player_name}'."}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid message type: #{type}"}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !dropmsg <player> <type> <message>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Set scrolling server message
|
||||
defp do_execute("slidemsg", args, _admin_state) do
|
||||
case args do
|
||||
[] ->
|
||||
{:error, "Usage: !slidemsg <message>"}
|
||||
|
||||
_ ->
|
||||
message = Enum.join(args, " ")
|
||||
Logger.info("Admin command: Setting slide message: #{message}")
|
||||
|
||||
# In a full implementation, this would broadcast to all channels
|
||||
# to update their server message
|
||||
|
||||
{:ok, "Server message set to: #{message}"}
|
||||
end
|
||||
end
|
||||
|
||||
# Request screenshot from player
|
||||
defp do_execute("screen", args, _admin_state) do
|
||||
case args do
|
||||
[player_name] ->
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
Logger.info("Admin command: Requesting screenshot from #{player_name}")
|
||||
|
||||
# Generate session key and send screenshot request
|
||||
session_key = :erlang.unique_integer([:positive])
|
||||
request_screenshot(character_id, session_key)
|
||||
|
||||
{:ok, "Screenshot requested from '#{player_name}'."}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !screen <player>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Process vote reward
|
||||
defp do_execute("vote", args, _admin_state) do
|
||||
case args do
|
||||
[account_id, account_name, result] ->
|
||||
case Integer.parse(account_id) do
|
||||
{acc_id, _} ->
|
||||
case Integer.parse(result) do
|
||||
{res, _} ->
|
||||
result_str = if res == 0, do: "Success", else: "Failure"
|
||||
Logger.info("Admin command: Vote processed - #{account_name} (#{acc_id}): #{result_str}")
|
||||
|
||||
# In a full implementation, this would:
|
||||
# 1. Find the character associated with account
|
||||
# 2. Grant vote rewards if successful
|
||||
# 3. Update last vote time
|
||||
|
||||
{:ok, "Vote recorded for #{account_name}."}
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid result code: #{result}"}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:error, "Invalid account ID: #{account_id}"}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !vote <account_id> <account_name> <result>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Start lie detector on player
|
||||
defp do_execute("liedetector", args, _admin_state) do
|
||||
case args do
|
||||
[player_name] ->
|
||||
case find_player(player_name) do
|
||||
nil ->
|
||||
{:error, "Player '#{player_name}' not found"}
|
||||
|
||||
character_id ->
|
||||
Logger.info("Admin command: Starting lie detector on #{player_name}")
|
||||
start_lie_detector(character_id)
|
||||
{:ok, "Lie detector started on '#{player_name}'."}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, "Usage: !liedetector <player>"}
|
||||
end
|
||||
end
|
||||
|
||||
# Reload configuration
|
||||
defp do_execute("reload", [], _admin_state) do
|
||||
Logger.info("Admin command: Reloading configuration")
|
||||
|
||||
# In a full implementation, this would:
|
||||
# 1. Reload config files
|
||||
# 2. Reload scripts if hot-reload is enabled
|
||||
# 3. Refresh various caches
|
||||
|
||||
{:ok, "Configuration reloaded."}
|
||||
end
|
||||
|
||||
defp do_execute("reload", _, _admin_state) do
|
||||
{:error, "Usage: !reload"}
|
||||
end
|
||||
|
||||
# Graceful shutdown
|
||||
defp do_execute("shutdown", args, _admin_state) do
|
||||
minutes = case args do
|
||||
[m] ->
|
||||
case Integer.parse(m) do
|
||||
{mins, _} -> mins
|
||||
:error -> 0
|
||||
end
|
||||
|
||||
[] ->
|
||||
0
|
||||
end
|
||||
|
||||
Logger.info("Admin command: Shutdown initiated (#{minutes} minutes)")
|
||||
|
||||
if minutes > 0 do
|
||||
# Schedule shutdown
|
||||
schedule_shutdown(minutes)
|
||||
{:ok, "Server will shutdown in #{minutes} minutes."}
|
||||
else
|
||||
# Immediate shutdown
|
||||
initiate_shutdown()
|
||||
{:ok, "Server is shutting down now."}
|
||||
end
|
||||
end
|
||||
|
||||
# Unknown command
|
||||
defp do_execute(command, _args, _admin_state) do
|
||||
{:error, "Unknown command: !#{command}"}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Finds a player by name across all channels.
|
||||
Returns character_id or nil.
|
||||
"""
|
||||
def find_player(name) do
|
||||
# First try local channel
|
||||
case Players.get_player_by_name(name) do
|
||||
nil ->
|
||||
# In a full implementation, this would query other channels via World
|
||||
# For now, try the character registry
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, name) do
|
||||
[{pid, _}] ->
|
||||
# Get character ID from the process
|
||||
case Character.get_state(pid) do
|
||||
%{character_id: id} -> id
|
||||
_ -> nil
|
||||
end
|
||||
[] -> nil
|
||||
end
|
||||
%{character_id: id} -> id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a player by character ID.
|
||||
"""
|
||||
def find_player_by_id(character_id) do
|
||||
case Players.get_player(character_id) do
|
||||
nil -> nil
|
||||
data -> data.character_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disconnects a player by character ID.
|
||||
"""
|
||||
def disconnect_player(character_id) do
|
||||
case Players.get_player(character_id) do
|
||||
nil -> :ok
|
||||
%{client_pid: client_pid} when is_pid(client_pid) ->
|
||||
# Send disconnect signal to client
|
||||
send(client_pid, :disconnect)
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends a drop message to a player.
|
||||
"""
|
||||
def drop_message(character_id, type, message) do
|
||||
case Players.get_player(character_id) do
|
||||
nil -> :ok
|
||||
%{client_pid: client_pid} when is_pid(client_pid) ->
|
||||
packet = Packets.drop_message(type, message)
|
||||
send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Notifies a player with a system message.
|
||||
"""
|
||||
def notify_player(character_id, message) do
|
||||
drop_message(character_id, 5, message)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Requests a screenshot from a player.
|
||||
"""
|
||||
def request_screenshot(character_id, session_key) do
|
||||
case Players.get_player(character_id) do
|
||||
nil -> :ok
|
||||
%{client_pid: client_pid} when is_pid(client_pid) ->
|
||||
packet = Packets.screenshot_request(session_key)
|
||||
send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a lie detector on a player.
|
||||
"""
|
||||
def start_lie_detector(character_id) do
|
||||
case Players.get_player(character_id) do
|
||||
nil -> :ok
|
||||
%{client_pid: client_pid} when is_pid(client_pid) ->
|
||||
packet = Packets.start_lie_detector()
|
||||
send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a server shutdown.
|
||||
"""
|
||||
def schedule_shutdown(minutes) do
|
||||
# Broadcast warning message to all players
|
||||
broadcast_server_message("Server will shutdown in #{minutes} minutes.")
|
||||
|
||||
# Schedule the actual shutdown
|
||||
Process.send_after(self(), :do_shutdown, minutes * 60 * 1000)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Initiates immediate shutdown.
|
||||
"""
|
||||
def initiate_shutdown do
|
||||
broadcast_server_message("Server is shutting down now!")
|
||||
|
||||
# Disconnect all players
|
||||
Players.clear()
|
||||
|
||||
# Signal application to stop
|
||||
System.stop(0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a message to all players on all channels.
|
||||
"""
|
||||
def broadcast_server_message(message) do
|
||||
# In a full implementation, this would use Redis pub/sub
|
||||
# For now, broadcast to all local players
|
||||
Players.get_all_players()
|
||||
|> Enum.each(fn %{client_pid: pid} ->
|
||||
if is_pid(pid), do: send(pid, {:send_packet, Packets.server_message(message)})
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a list of available commands for help display.
|
||||
"""
|
||||
def list_commands do
|
||||
[
|
||||
{"ban", "<player> [reason] [hell]", "Ban a player"},
|
||||
{"dc", "<player>", "Disconnect a player"},
|
||||
{"dcall", "", "Disconnect all players"},
|
||||
{"dcchannel", "<channel>", "Disconnect all players in a channel"},
|
||||
{"warp", "<player> <map_id>", "Warp player to map"},
|
||||
{"dropmsg", "<player> <type> <message>", "Send drop message to player"},
|
||||
{"slidemsg", "<message>", "Set scrolling server message"},
|
||||
{"screen", "<player>", "Request screenshot from player"},
|
||||
{"vote", "<account_id> <name> <result>", "Process vote reward"},
|
||||
{"liedetector", "<player>", "Start lie detector on player"},
|
||||
{"reload", "", "Reload configuration"},
|
||||
{"shutdown", "[minutes]", "Graceful server shutdown"}
|
||||
]
|
||||
end
|
||||
end
|
||||
150
lib/odinsea/admin/handler.ex
Normal file
150
lib/odinsea/admin/handler.ex
Normal file
@@ -0,0 +1,150 @@
|
||||
defmodule Odinsea.Admin.Handler do
|
||||
@moduledoc """
|
||||
Main admin command handler.
|
||||
Ported from Java handling.admin.AdminHandler
|
||||
|
||||
Parses chat messages starting with '!' as admin commands
|
||||
and routes them to the appropriate command implementation.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Admin.Commands
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Parses and executes an admin command from chat message.
|
||||
|
||||
Commands start with '!' followed by the command name and arguments.
|
||||
Example: "!warp PlayerName 100000000"
|
||||
|
||||
Returns:
|
||||
- {:ok, result_message} - Command executed successfully
|
||||
- {:error, reason} - Command failed
|
||||
- :not_command - Message is not an admin command
|
||||
"""
|
||||
def handle_command(message, client_state) when is_binary(message) do
|
||||
# Check if message is a command (starts with !)
|
||||
if String.starts_with?(message, "!") do
|
||||
parse_and_execute(message, client_state)
|
||||
else
|
||||
:not_command
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses command string and executes.
|
||||
"""
|
||||
def parse_and_execute(message, client_state) do
|
||||
# Remove leading '!' and split into command and arguments
|
||||
command_str = String.slice(message, 1..-1//-1)
|
||||
parts = String.split(command_str)
|
||||
|
||||
case parts do
|
||||
[] ->
|
||||
{:error, "Empty command"}
|
||||
|
||||
[command | args] ->
|
||||
command = String.downcase(command)
|
||||
|
||||
char_id = if client_state.character_id, do: client_state.character_id, else: "unknown"
|
||||
Logger.info("Admin command from #{char_id}: #{command} #{inspect(args)}")
|
||||
|
||||
# Get admin state (character info with GM level)
|
||||
admin_state = build_admin_state(client_state)
|
||||
|
||||
case Commands.execute(command, args, admin_state) do
|
||||
{:ok, result} ->
|
||||
# Send success message back to admin
|
||||
notify_admin(client_state, result)
|
||||
{:ok, result}
|
||||
|
||||
{:error, reason} ->
|
||||
# Send error message back to admin
|
||||
notify_admin(client_state, "Error: #{reason}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a message is an admin command.
|
||||
"""
|
||||
def admin_command?(message) do
|
||||
String.starts_with?(message, "!")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the command name from a message (for logging).
|
||||
"""
|
||||
def extract_command_name(message) do
|
||||
case String.split(message) do
|
||||
[first | _] -> String.downcase(String.trim_leading(first, "!"))
|
||||
_ -> "unknown"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends help information to the admin.
|
||||
"""
|
||||
def send_help(client_state) do
|
||||
commands = Commands.list_commands()
|
||||
|
||||
help_text = [
|
||||
"=== Admin Commands ===",
|
||||
""
|
||||
| Enum.map(commands, fn {cmd, args, desc} ->
|
||||
"!#{cmd} #{args} - #{desc}"
|
||||
end)
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
|
||||
notify_admin(client_state, help_text)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp build_admin_state(client_state) do
|
||||
# Get character information including GM level
|
||||
gm_level = get_gm_level(client_state)
|
||||
|
||||
%{
|
||||
character_id: client_state.character_id,
|
||||
channel_id: client_state.channel_id,
|
||||
gm_level: gm_level,
|
||||
client_pid: self()
|
||||
}
|
||||
end
|
||||
|
||||
defp get_gm_level(client_state) do
|
||||
# Try to get GM level from character
|
||||
case client_state.character_id do
|
||||
nil -> 0
|
||||
character_id ->
|
||||
# In a full implementation, this would query the character state
|
||||
# For now, use a default or check player storage
|
||||
case Odinsea.Channel.Players.get_player(character_id) do
|
||||
nil -> 0
|
||||
player_data -> Map.get(player_data, :gm, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_admin(client_state, message) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
:ok
|
||||
character_id ->
|
||||
case Odinsea.Channel.Players.get_player(character_id) do
|
||||
nil -> :ok
|
||||
%{client_pid: pid} when is_pid(pid) ->
|
||||
packet = Packets.drop_message(5, message)
|
||||
send(pid, {:send_packet, packet})
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
285
lib/odinsea/anticheat.ex
Normal file
285
lib/odinsea/anticheat.ex
Normal file
@@ -0,0 +1,285 @@
|
||||
defmodule Odinsea.AntiCheat do
|
||||
@moduledoc """
|
||||
Anti-Cheat system for Odinsea.
|
||||
|
||||
Ported from Java:
|
||||
- client.anticheat.CheatTracker
|
||||
- client.anticheat.CheatingOffense
|
||||
- client.anticheat.CheatingOffenseEntry
|
||||
- server.AutobanManager
|
||||
|
||||
This module provides:
|
||||
- Damage validation
|
||||
- Movement validation
|
||||
- Item validation
|
||||
- EXP validation
|
||||
- Autoban system with threshold-based banning
|
||||
- Offense tracking with expiration
|
||||
"""
|
||||
|
||||
alias Odinsea.AntiCheat.{CheatTracker, CheatingOffense, CheatingOffenseEntry}
|
||||
|
||||
# Re-export main modules
|
||||
defdelegate start_tracker(character_id, character_pid), to: CheatTracker
|
||||
defdelegate stop_tracker(character_id), to: CheatTracker
|
||||
defdelegate register_offense(character_id, offense, param \\ nil), to: CheatTracker
|
||||
defdelegate get_points(character_id), to: CheatTracker
|
||||
defdelegate get_summary(character_id), to: CheatTracker
|
||||
defdelegate check_attack(character_id, skill_id, tick_count), to: CheatTracker
|
||||
defdelegate check_take_damage(character_id, damage), to: CheatTracker
|
||||
defdelegate check_same_damage(character_id, damage, expected), to: CheatTracker
|
||||
defdelegate check_drop(character_id, dc \\ false), to: CheatTracker
|
||||
defdelegate check_message(character_id), to: CheatTracker
|
||||
defdelegate can_smega(character_id), to: CheatTracker
|
||||
defdelegate can_avatar_smega(character_id), to: CheatTracker
|
||||
defdelegate can_bbs(character_id), to: CheatTracker
|
||||
defdelegate update_tick(character_id, new_tick), to: CheatTracker
|
||||
defdelegate check_summon_attack(character_id), to: CheatTracker
|
||||
defdelegate reset_summon_attack(character_id), to: CheatTracker
|
||||
defdelegate check_familiar_attack(character_id), to: CheatTracker
|
||||
defdelegate reset_familiar_attack(character_id), to: CheatTracker
|
||||
defdelegate set_attacks_without_hit(character_id, increase), to: CheatTracker
|
||||
defdelegate get_attacks_without_hit(character_id), to: CheatTracker
|
||||
defdelegate check_move_monsters(character_id, position), to: CheatTracker
|
||||
defdelegate get_offenses(character_id), to: CheatTracker
|
||||
|
||||
# CheatingOffense access
|
||||
def get_offense_types do
|
||||
CheatingOffense.all_offenses()
|
||||
end
|
||||
|
||||
def get_offense_points(offense_type) do
|
||||
CheatingOffense.get_points(offense_type)
|
||||
end
|
||||
|
||||
def should_autoban?(offense_type, count) do
|
||||
CheatingOffense.should_autoban?(offense_type, count)
|
||||
end
|
||||
|
||||
def get_ban_type(offense_type) do
|
||||
CheatingOffense.get_ban_type(offense_type)
|
||||
end
|
||||
|
||||
def is_enabled?(offense_type) do
|
||||
CheatingOffense.is_enabled?(offense_type)
|
||||
end
|
||||
|
||||
def get_validity_duration(offense_type) do
|
||||
CheatingOffense.get_validity_duration(offense_type)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Odinsea.AntiCheat.CheatingOffense do
|
||||
@moduledoc """
|
||||
Defines all cheating offenses and their properties.
|
||||
|
||||
Ported from: client.anticheat.CheatingOffense.java
|
||||
|
||||
Each offense has:
|
||||
- points: Points added per occurrence
|
||||
- validity_duration: How long the offense stays active (ms)
|
||||
- autoban_count: Number of occurrences before autoban (-1 = disabled)
|
||||
- ban_type: 0 = disabled, 1 = ban, 2 = DC
|
||||
"""
|
||||
|
||||
@type offense_type :: atom()
|
||||
@type ban_type :: :disabled | :ban | :disconnect
|
||||
|
||||
@offenses %{
|
||||
# Offense type => {points, validity_duration_ms, autoban_count, ban_type}
|
||||
# ban_type: 0 = disabled, 1 = ban, 2 = disconnect
|
||||
|
||||
# Attack speed offenses
|
||||
:fast_summon_attack => {5, 6_000, 50, :disconnect},
|
||||
:fast_attack => {5, 6_000, 200, :disconnect},
|
||||
:fast_attack_2 => {5, 6_000, 500, :disconnect},
|
||||
|
||||
# Movement offenses
|
||||
:move_monsters => {5, 30_000, 500, :disconnect},
|
||||
:high_jump => {1, 60_000, -1, :disabled},
|
||||
:using_faraway_portal => {1, 60_000, 100, :disabled},
|
||||
|
||||
# Regen offenses
|
||||
:fast_hp_mp_regen => {5, 20_000, 100, :disconnect},
|
||||
:regen_high_hp => {10, 30_000, 1000, :disconnect},
|
||||
:regen_high_mp => {10, 30_000, 1000, :disconnect},
|
||||
|
||||
# Damage offenses
|
||||
:same_damage => {5, 180_000, -1, :disconnect},
|
||||
:attack_without_getting_hit => {1, 30_000, 1200, :disabled},
|
||||
:high_damage_magic => {5, 30_000, -1, :disabled},
|
||||
:high_damage_magic_2 => {10, 180_000, -1, :ban},
|
||||
:high_damage => {5, 30_000, -1, :disabled},
|
||||
:high_damage_2 => {10, 180_000, -1, :ban},
|
||||
:exceed_damage_cap => {5, 60_000, 800, :disabled},
|
||||
:attack_faraway_monster => {5, 180_000, -1, :disabled},
|
||||
:attack_faraway_monster_summon => {5, 180_000, 200, :disconnect},
|
||||
|
||||
# Item offenses
|
||||
:itemvac_client => {3, 10_000, 100, :disabled},
|
||||
:itemvac_server => {2, 10_000, 100, :disconnect},
|
||||
:pet_itemvac_client => {3, 10_000, 100, :disabled},
|
||||
:pet_itemvac_server => {2, 10_000, 100, :disconnect},
|
||||
:using_unavailable_item => {1, 300_000, -1, :ban},
|
||||
|
||||
# Combat offenses
|
||||
:fast_take_damage => {1, 60_000, 100, :disabled},
|
||||
:high_avoid => {5, 180_000, 100, :disabled},
|
||||
:mismatching_bulletcount => {1, 300_000, -1, :ban},
|
||||
:etc_explosion => {1, 300_000, -1, :ban},
|
||||
:attacking_while_dead => {1, 300_000, -1, :ban},
|
||||
:exploding_nonexistant => {1, 300_000, -1, :ban},
|
||||
:summon_hack => {1, 300_000, -1, :ban},
|
||||
:summon_hack_mobs => {1, 300_000, -1, :ban},
|
||||
:aran_combo_hack => {1, 600_000, 50, :disconnect},
|
||||
:heal_attacking_undead => {20, 30_000, 100, :disabled},
|
||||
|
||||
# Social offenses
|
||||
:faming_self => {1, 300_000, -1, :ban},
|
||||
:faming_under_15 => {1, 300_000, -1, :ban}
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns all offense types.
|
||||
"""
|
||||
@spec all_offenses() :: list(offense_type())
|
||||
def all_offenses do
|
||||
Map.keys(@offenses)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the points for an offense type.
|
||||
"""
|
||||
@spec get_points(offense_type()) :: integer()
|
||||
def get_points(offense_type) do
|
||||
case Map.get(@offenses, offense_type) do
|
||||
{points, _, _, _} -> points
|
||||
nil -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the validity duration for an offense type.
|
||||
"""
|
||||
@spec get_validity_duration(offense_type()) :: integer()
|
||||
def get_validity_duration(offense_type) do
|
||||
case Map.get(@offenses, offense_type) do
|
||||
{_, duration, _, _} -> duration
|
||||
nil -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if an offense should trigger autoban at the given count.
|
||||
"""
|
||||
@spec should_autoban?(offense_type(), integer()) :: boolean()
|
||||
def should_autoban?(offense_type, count) do
|
||||
case Map.get(@offenses, offense_type) do
|
||||
{_, _, autoban_count, _} when autoban_count > 0 -> count >= autoban_count
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the ban type for an offense.
|
||||
"""
|
||||
@spec get_ban_type(offense_type()) :: ban_type()
|
||||
def get_ban_type(offense_type) do
|
||||
case Map.get(@offenses, offense_type) do
|
||||
{_, _, _, ban_type} -> ban_type
|
||||
nil -> :disabled
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if an offense type is enabled (ban_type >= 1).
|
||||
"""
|
||||
@spec is_enabled?(offense_type()) :: boolean()
|
||||
def is_enabled?(offense_type) do
|
||||
case get_ban_type(offense_type) do
|
||||
:disabled -> false
|
||||
_ -> true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Odinsea.AntiCheat.CheatingOffenseEntry do
|
||||
@moduledoc """
|
||||
Represents a single cheating offense entry for a character.
|
||||
|
||||
Ported from: client.anticheat.CheatingOffenseEntry.java
|
||||
"""
|
||||
|
||||
alias Odinsea.AntiCheat.CheatingOffense
|
||||
|
||||
defstruct [
|
||||
:offense_type,
|
||||
:character_id,
|
||||
:count,
|
||||
:last_offense_time,
|
||||
:param
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
offense_type: CheatingOffense.offense_type(),
|
||||
character_id: integer(),
|
||||
count: integer(),
|
||||
last_offense_time: integer() | nil,
|
||||
param: String.t() | nil
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new offense entry.
|
||||
"""
|
||||
@spec new(CheatingOffense.offense_type(), integer()) :: t()
|
||||
def new(offense_type, character_id) do
|
||||
%__MODULE__{
|
||||
offense_type: offense_type,
|
||||
character_id: character_id,
|
||||
count: 0,
|
||||
last_offense_time: nil,
|
||||
param: nil
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments the offense count and updates timestamp.
|
||||
"""
|
||||
@spec increment(t()) :: t()
|
||||
def increment(entry) do
|
||||
%__MODULE__{entry |
|
||||
count: entry.count + 1,
|
||||
last_offense_time: System.monotonic_time(:millisecond)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a parameter for the offense (additional info).
|
||||
"""
|
||||
@spec set_param(t(), String.t()) :: t()
|
||||
def set_param(entry, param) do
|
||||
%__MODULE__{entry | param: param}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the offense entry has expired.
|
||||
"""
|
||||
@spec expired?(t()) :: boolean()
|
||||
def expired?(entry) do
|
||||
if entry.last_offense_time == nil do
|
||||
false
|
||||
else
|
||||
validity_duration = CheatingOffense.get_validity_duration(entry.offense_type)
|
||||
now = System.monotonic_time(:millisecond)
|
||||
now - entry.last_offense_time > validity_duration
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the total points for this offense entry.
|
||||
"""
|
||||
@spec get_points(t()) :: integer()
|
||||
def get_points(entry) do
|
||||
entry.count * CheatingOffense.get_points(entry.offense_type)
|
||||
end
|
||||
end
|
||||
250
lib/odinsea/anticheat/autoban_manager.ex
Normal file
250
lib/odinsea/anticheat/autoban_manager.ex
Normal file
@@ -0,0 +1,250 @@
|
||||
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
|
||||
79
lib/odinsea/anticheat/cheater_data.ex
Normal file
79
lib/odinsea/anticheat/cheater_data.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule Odinsea.AntiCheat.CheaterData do
|
||||
@moduledoc """
|
||||
Data structure for tracking cheaters.
|
||||
|
||||
Ported from: handling.world.CheaterData.java
|
||||
|
||||
Stores information about a cheating offense for reporting/broadcasting:
|
||||
- points: The point value of the offense
|
||||
- info: Description of the offense
|
||||
"""
|
||||
|
||||
defstruct [:points, :info]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
points: integer(),
|
||||
info: String.t()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new CheaterData entry.
|
||||
"""
|
||||
@spec new(integer(), String.t()) :: t()
|
||||
def new(points, info) do
|
||||
%__MODULE__{
|
||||
points: points,
|
||||
info: info
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compares two CheaterData entries by points (descending order).
|
||||
"""
|
||||
@spec compare(t(), t()) :: :gt | :eq | :lt
|
||||
def compare(%__MODULE__{points: p1}, %__MODULE__{points: p2}) do
|
||||
cond do
|
||||
p1 > p2 -> :gt
|
||||
p1 == p2 -> :eq
|
||||
true -> :lt
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sorts a list of CheaterData by points (highest first).
|
||||
"""
|
||||
@spec sort_by_points(list(t())) :: list(t())
|
||||
def sort_by_points(cheater_data_list) do
|
||||
Enum.sort(cheater_data_list, fn a, b ->
|
||||
compare(a, b) == :gt
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the top N cheaters by points.
|
||||
"""
|
||||
@spec top_cheaters(list(t()), integer()) :: list(t())
|
||||
def top_cheaters(cheater_data_list, n) do
|
||||
cheater_data_list
|
||||
|> sort_by_points()
|
||||
|> Enum.take(n)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates total points from a list of CheaterData.
|
||||
"""
|
||||
@spec total_points(list(t())) :: integer()
|
||||
def total_points(cheater_data_list) do
|
||||
Enum.reduce(cheater_data_list, 0, fn data, acc ->
|
||||
acc + data.points
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats CheaterData for display/logging.
|
||||
"""
|
||||
@spec format(t()) :: String.t()
|
||||
def format(%__MODULE__{points: points, info: info}) do
|
||||
"[#{points} pts] #{info}"
|
||||
end
|
||||
end
|
||||
569
lib/odinsea/anticheat/lie_detector.ex
Normal file
569
lib/odinsea/anticheat/lie_detector.ex
Normal file
@@ -0,0 +1,569 @@
|
||||
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
|
||||
891
lib/odinsea/anticheat/monitor.ex
Normal file
891
lib/odinsea/anticheat/monitor.ex
Normal file
@@ -0,0 +1,891 @@
|
||||
defmodule Odinsea.AntiCheat.CheatTracker do
|
||||
@moduledoc """
|
||||
Main cheat tracking module per character.
|
||||
|
||||
Ported from: client.anticheat.CheatTracker.java
|
||||
|
||||
This is a GenServer that tracks all anti-cheat state for a single character:
|
||||
- Offense history with expiration
|
||||
- Attack timing tracking (speed hack detection)
|
||||
- Damage validation state
|
||||
- Movement validation state
|
||||
- Drop/message rate limiting
|
||||
- GM alerts for suspicious activity
|
||||
|
||||
## State Structure
|
||||
|
||||
- `offenses`: Map of offense_type => CheatingOffenseEntry
|
||||
- `character_id`: The character being tracked
|
||||
- `character_pid`: PID of the character GenServer
|
||||
- `last_attack_time`: Timestamp of last attack
|
||||
- `last_attack_tick_count`: Client tick count at last attack
|
||||
- `attack_tick_reset_count`: Counter for tick synchronization
|
||||
- `server_client_atk_tick_diff`: Time difference tracker
|
||||
- `last_damage`: Last damage dealt
|
||||
- `taking_damage_since`: When continuous damage started
|
||||
- `num_sequential_damage`: Count of sequential damage events
|
||||
- `last_damage_taken_time`: Timestamp of last damage taken
|
||||
- `num_zero_damage_taken`: Count of zero damage events (avoid)
|
||||
- `num_same_damage`: Count of identical damage values
|
||||
- `drops_per_second`: Drop rate counter
|
||||
- `last_drop_time`: Timestamp of last drop
|
||||
- `msgs_per_second`: Message rate counter
|
||||
- `last_msg_time`: Timestamp of last message
|
||||
- `attacks_without_hit`: Counter for attacks without being hit
|
||||
- `gm_message`: Counter for GM alerts
|
||||
- `last_tick_count`: Last client tick seen
|
||||
- `tick_same`: Counter for duplicate ticks (packet spam)
|
||||
- `last_smega_time`: Last super megaphone use
|
||||
- `last_avatar_smega_time`: Last avatar megaphone use
|
||||
- `last_bbs_time`: Last BBS use
|
||||
- `summon_summon_time`: Summon activation time
|
||||
- `num_sequential_summon_attack`: Sequential summon attack count
|
||||
- `familiar_summon_time`: Familiar activation time
|
||||
- `num_sequential_familiar_attack`: Sequential familiar attack count
|
||||
- `last_monster_move`: Last monster position
|
||||
- `monster_move_count`: Monster move counter
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.AntiCheat.{CheatingOffense, CheatingOffenseEntry, AutobanManager}
|
||||
alias Odinsea.Constants.Game
|
||||
|
||||
@table :cheat_trackers
|
||||
|
||||
# GM alert threshold - broadcast every 100 offenses
|
||||
@gm_alert_threshold 100
|
||||
@gm_autoban_threshold 300
|
||||
|
||||
# Client/Server time difference threshold (ms)
|
||||
@time_diff_threshold 1000
|
||||
|
||||
# How often to run invalidation task (ms)
|
||||
@invalidation_interval 60_000
|
||||
|
||||
# =============================================================================
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Starts a cheat tracker for a character.
|
||||
"""
|
||||
def start_tracker(character_id, character_pid) do
|
||||
# Ensure ETS table exists
|
||||
case :ets.info(@table) do
|
||||
:undefined ->
|
||||
:ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.CheatTrackerSupervisor,
|
||||
{__MODULE__, {character_id, character_pid}}
|
||||
) do
|
||||
{:ok, pid} ->
|
||||
:ets.insert(@table, {character_id, pid})
|
||||
{:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the cheat tracker for a character.
|
||||
"""
|
||||
def stop_tracker(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid ->
|
||||
GenServer.stop(pid, :normal)
|
||||
:ets.delete(@table, character_id)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a tracker PID by character ID.
|
||||
"""
|
||||
def lookup_tracker(character_id) do
|
||||
case :ets.lookup(@table, character_id) do
|
||||
[{^character_id, pid}] -> pid
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a cheating offense for a character.
|
||||
"""
|
||||
def register_offense(character_id, offense, param \\ nil) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :error
|
||||
pid -> GenServer.call(pid, {:register_offense, offense, param})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total cheat points for a character.
|
||||
"""
|
||||
def get_points(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> 0
|
||||
pid -> GenServer.call(pid, :get_points)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a summary of offenses for a character.
|
||||
"""
|
||||
def get_summary(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> ""
|
||||
pid -> GenServer.call(pid, :get_summary)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all active offenses for a character.
|
||||
"""
|
||||
def get_offenses(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> %{}
|
||||
pid -> GenServer.call(pid, :get_offenses)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks attack timing for speed hack detection.
|
||||
"""
|
||||
def check_attack(character_id, skill_id, tick_count) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_attack, skill_id, tick_count})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks damage taken rate.
|
||||
"""
|
||||
def check_take_damage(character_id, damage) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_take_damage, damage})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks for same damage values (damage hack detection).
|
||||
"""
|
||||
def check_same_damage(character_id, damage, expected) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_same_damage, damage, expected})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks drop rate.
|
||||
"""
|
||||
def check_drop(character_id, dc \\ false) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_drop, dc})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks message rate.
|
||||
"""
|
||||
def check_message(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :check_message)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use super megaphone.
|
||||
"""
|
||||
def can_smega(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_smega)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use avatar megaphone.
|
||||
"""
|
||||
def can_avatar_smega(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_avatar_smega)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character can use BBS.
|
||||
"""
|
||||
def can_bbs(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :can_bbs)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates client tick count and detects packet spam.
|
||||
"""
|
||||
def update_tick(character_id, new_tick) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:update_tick, new_tick})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks summon attack rate.
|
||||
"""
|
||||
def check_summon_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :check_summon_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets summon attack tracking.
|
||||
"""
|
||||
def reset_summon_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :reset_summon_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks familiar attack rate.
|
||||
"""
|
||||
def check_familiar_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> true
|
||||
pid -> GenServer.call(pid, :check_familiar_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets familiar attack tracking.
|
||||
"""
|
||||
def reset_familiar_attack(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, :reset_familiar_attack)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets attacks without hit counter.
|
||||
"""
|
||||
def set_attacks_without_hit(character_id, increase) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:set_attacks_without_hit, increase})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets attacks without hit count.
|
||||
"""
|
||||
def get_attacks_without_hit(character_id) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> 0
|
||||
pid -> GenServer.call(pid, :get_attacks_without_hit)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks for suspicious monster movement (move monster hack).
|
||||
"""
|
||||
def check_move_monsters(character_id, position) do
|
||||
case lookup_tracker(character_id) do
|
||||
nil -> :ok
|
||||
pid -> GenServer.call(pid, {:check_move_monsters, position})
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# GenServer Callbacks
|
||||
# =============================================================================
|
||||
|
||||
def start_link({character_id, character_pid}) do
|
||||
GenServer.start_link(__MODULE__, {character_id, character_pid})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({character_id, character_pid}) do
|
||||
# Start invalidation timer
|
||||
schedule_invalidation()
|
||||
|
||||
{:ok, %{
|
||||
character_id: character_id,
|
||||
character_pid: character_pid,
|
||||
offenses: %{},
|
||||
|
||||
# Attack timing
|
||||
last_attack_time: 0,
|
||||
last_attack_tick_count: 0,
|
||||
attack_tick_reset_count: 0,
|
||||
server_client_atk_tick_diff: 0,
|
||||
|
||||
# Damage tracking
|
||||
last_damage: 0,
|
||||
taking_damage_since: System.monotonic_time(:millisecond),
|
||||
num_sequential_damage: 0,
|
||||
last_damage_taken_time: 0,
|
||||
num_zero_damage_taken: 0,
|
||||
num_same_damage: 0,
|
||||
|
||||
# Rate limiting
|
||||
drops_per_second: 0,
|
||||
last_drop_time: 0,
|
||||
msgs_per_second: 0,
|
||||
last_msg_time: 0,
|
||||
|
||||
# Combat tracking
|
||||
attacks_without_hit: 0,
|
||||
|
||||
# GM alerts
|
||||
gm_message: 0,
|
||||
|
||||
# Tick tracking
|
||||
last_tick_count: 0,
|
||||
tick_same: 0,
|
||||
|
||||
# Megaphone/BBS tracking
|
||||
last_smega_time: 0,
|
||||
last_avatar_smega_time: 0,
|
||||
last_bbs_time: 0,
|
||||
|
||||
# Summon tracking
|
||||
summon_summon_time: 0,
|
||||
num_sequential_summon_attack: 0,
|
||||
|
||||
# Familiar tracking
|
||||
familiar_summon_time: 0,
|
||||
num_sequential_familiar_attack: 0,
|
||||
|
||||
# Monster movement tracking
|
||||
last_monster_move: nil,
|
||||
monster_move_count: 0
|
||||
}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:register_offense, offense, param}, _from, state) do
|
||||
new_state = do_register_offense(state, offense, param)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_points, _from, state) do
|
||||
points = calculate_points(state)
|
||||
{:reply, points, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_summary, _from, state) do
|
||||
summary = build_summary(state)
|
||||
{:reply, summary, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_offenses, _from, state) do
|
||||
{:reply, state.offenses, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_attack, skill_id, tick_count}, _from, state) do
|
||||
new_state = check_attack_timing(state, skill_id, tick_count)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_take_damage, damage}, _from, state) do
|
||||
new_state = check_damage_taken(state, damage)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_same_damage, damage, expected}, _from, state) do
|
||||
new_state = check_same_damage_value(state, damage, expected)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_drop, dc}, _from, state) do
|
||||
new_state = check_drop_rate(state, dc)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_message, _from, state) do
|
||||
new_state = check_msg_rate(state)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_smega, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_smega_time >= 15_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_smega_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_avatar_smega, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_avatar_smega_time >= 300_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_avatar_smega_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:can_bbs, _from, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
can_use = now - state.last_bbs_time >= 60_000
|
||||
|
||||
new_state = if can_use do
|
||||
%{state | last_bbs_time: now}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:reply, can_use, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_tick, new_tick}, _from, state) do
|
||||
new_state = handle_tick_update(state, new_tick)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_summon_attack, _from, state) do
|
||||
{result, new_state} = check_summon_timing(state)
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reset_summon_attack, _from, state) do
|
||||
new_state = %{state |
|
||||
summon_summon_time: System.monotonic_time(:millisecond),
|
||||
num_sequential_summon_attack: 0
|
||||
}
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:check_familiar_attack, _from, state) do
|
||||
{result, new_state} = check_familiar_timing(state)
|
||||
{:reply, result, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reset_familiar_attack, _from, state) do
|
||||
new_state = %{state |
|
||||
familiar_summon_time: System.monotonic_time(:millisecond),
|
||||
num_sequential_familiar_attack: 0
|
||||
}
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_attacks_without_hit, increase}, _from, state) do
|
||||
new_count = if increase do
|
||||
state.attacks_without_hit + 1
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
new_state = %{state | attacks_without_hit: new_count}
|
||||
{:reply, new_count, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_attacks_without_hit, _from, state) do
|
||||
{:reply, state.attacks_without_hit, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:check_move_monsters, position}, _from, state) do
|
||||
new_state = check_monster_movement(state, position)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:invalidate_offenses, state) do
|
||||
new_offenses =
|
||||
state.offenses
|
||||
|> Enum.reject(fn {_type, entry} -> CheatingOffenseEntry.expired?(entry) end)
|
||||
|> Map.new()
|
||||
|
||||
# Check if character still exists
|
||||
if Process.alive?(state.character_pid) do
|
||||
schedule_invalidation()
|
||||
{:noreply, %{state | offenses: new_offenses}}
|
||||
else
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_msg, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Private Functions
|
||||
# =============================================================================
|
||||
|
||||
defp schedule_invalidation do
|
||||
Process.send_after(self(), :invalidate_offenses, @invalidation_interval)
|
||||
end
|
||||
|
||||
defp do_register_offense(state, offense, param) do
|
||||
# Skip if offense is disabled
|
||||
if CheatingOffense.is_enabled?(offense) do
|
||||
# Check if we already have an entry
|
||||
entry = Map.get(state.offenses, offense)
|
||||
|
||||
# Expire old entry if needed
|
||||
entry = if entry && CheatingOffenseEntry.expired?(entry) do
|
||||
nil
|
||||
else
|
||||
entry
|
||||
end
|
||||
|
||||
# Create new entry if needed
|
||||
entry = entry || CheatingOffenseEntry.new(offense, state.character_id)
|
||||
|
||||
# Set param if provided
|
||||
entry = if param, do: CheatingOffenseEntry.set_param(entry, param), else: entry
|
||||
|
||||
# Increment count
|
||||
entry = CheatingOffenseEntry.increment(entry)
|
||||
|
||||
# Check for autoban
|
||||
state = check_autoban(state, offense, entry, param)
|
||||
|
||||
# Store entry
|
||||
offenses = Map.put(state.offenses, offense, entry)
|
||||
|
||||
%{state | offenses: offenses}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp check_autoban(state, offense, entry, param) do
|
||||
if CheatingOffense.should_autoban?(offense, entry.count) do
|
||||
ban_type = CheatingOffense.get_ban_type(offense)
|
||||
|
||||
case ban_type do
|
||||
:ban ->
|
||||
AutobanManager.autoban(state.character_id, offense_name(offense))
|
||||
|
||||
:disconnect ->
|
||||
# Log DC attempt
|
||||
Logger.warning("[AntiCheat] DC triggered for char #{state.character_id}: #{offense_name(offense)}")
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
%{state | gm_message: 0}
|
||||
else
|
||||
# Check for GM alerts on certain offenses
|
||||
check_gm_alert(state, offense, entry, param)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_gm_alert(state, offense, entry, param) do
|
||||
alert_offenses = [
|
||||
:high_damage_magic_2,
|
||||
:high_damage_2,
|
||||
:attack_faraway_monster,
|
||||
:attack_faraway_monster_summon,
|
||||
:same_damage
|
||||
]
|
||||
|
||||
if offense in alert_offenses do
|
||||
new_gm_count = state.gm_message + 1
|
||||
|
||||
# Broadcast to GMs every 100 occurrences
|
||||
if rem(new_gm_count, @gm_alert_threshold) == 0 do
|
||||
msg = "#{state.character_id} is suspected of hacking! #{offense_name(offense)}"
|
||||
msg = if param, do: "#{msg} - #{param}", else: msg
|
||||
broadcast_gm_alert(msg)
|
||||
end
|
||||
|
||||
# Check for autoban after 300 offenses
|
||||
if new_gm_count >= @gm_autoban_threshold do
|
||||
Logger.warning("[AntiCheat] High offense count for char #{state.character_id}")
|
||||
end
|
||||
|
||||
%{state | gm_message: new_gm_count}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_gm_alert(_msg) do
|
||||
# TODO: Implement GM alert broadcasting through World service
|
||||
:ok
|
||||
end
|
||||
|
||||
defp calculate_points(state) do
|
||||
state.offenses
|
||||
|> Map.values()
|
||||
|> Enum.reject(&CheatingOffenseEntry.expired?/1)
|
||||
|> Enum.map(&CheatingOffenseEntry.get_points/1)
|
||||
|> Enum.sum()
|
||||
end
|
||||
|
||||
defp build_summary(state) do
|
||||
sorted_offenses =
|
||||
state.offenses
|
||||
|> Map.values()
|
||||
|> Enum.reject(&CheatingOffenseEntry.expired?/1)
|
||||
|> Enum.sort_by(&CheatingOffenseEntry.get_points/1, :desc)
|
||||
|> Enum.take(4)
|
||||
|
||||
sorted_offenses
|
||||
|> Enum.map(fn entry ->
|
||||
"#{offense_name(entry.offense_type)}: #{entry.count}"
|
||||
end)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp check_attack_timing(state, skill_id, tick_count) do
|
||||
# Get attack delay for skill
|
||||
atk_delay = Game.get_attack_delay(skill_id)
|
||||
|
||||
# Check for fast attack
|
||||
state = if tick_count - state.last_attack_tick_count < atk_delay do
|
||||
do_register_offense(state, :fast_attack, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
# Update attack time
|
||||
state = %{state | last_attack_time: now}
|
||||
|
||||
# Check server/client tick difference
|
||||
st_time_tc = now - tick_count
|
||||
diff = state.server_client_atk_tick_diff - st_time_tc
|
||||
|
||||
state = if diff > @time_diff_threshold do
|
||||
do_register_offense(state, :fast_attack_2, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
# Update tick counters
|
||||
reset_count = state.attack_tick_reset_count + 1
|
||||
reset_threshold = if atk_delay <= 200, do: 1, else: 4
|
||||
|
||||
if reset_count >= reset_threshold do
|
||||
%{state |
|
||||
attack_tick_reset_count: 0,
|
||||
server_client_atk_tick_diff: st_time_tc,
|
||||
last_attack_tick_count: tick_count
|
||||
}
|
||||
else
|
||||
%{state |
|
||||
attack_tick_reset_count: reset_count,
|
||||
last_attack_tick_count: tick_count
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_damage_taken(state, damage) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
new_sequential = state.num_sequential_damage + 1
|
||||
|
||||
# Check fast take damage
|
||||
time_since_start = now - state.taking_damage_since
|
||||
|
||||
state = if time_since_start / 500 < new_sequential do
|
||||
do_register_offense(state, :fast_take_damage, nil)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
# Reset if more than 4.5 seconds
|
||||
{new_sequential, new_since} = if time_since_start > 4500 do
|
||||
{0, now}
|
||||
else
|
||||
{new_sequential, state.taking_damage_since}
|
||||
end
|
||||
|
||||
# Track zero damage (avoid hack)
|
||||
new_zero_count = if damage == 0 do
|
||||
state.num_zero_damage_taken + 1
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
%{state |
|
||||
num_sequential_damage: new_sequential,
|
||||
taking_damage_since: new_since,
|
||||
last_damage_taken_time: now,
|
||||
num_zero_damage_taken: new_zero_count
|
||||
}
|
||||
end
|
||||
|
||||
defp check_same_damage_value(state, damage, expected) do
|
||||
# Only check significant damage
|
||||
if damage > 2000 && state.last_damage == damage do
|
||||
new_count = state.num_same_damage + 1
|
||||
|
||||
state = if new_count > 5 do
|
||||
do_register_offense(state, :same_damage,
|
||||
"#{new_count} times, damage #{damage}, expected #{expected}")
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
%{state | num_same_damage: new_count}
|
||||
else
|
||||
%{state |
|
||||
last_damage: damage,
|
||||
num_same_damage: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_drop_rate(state, _dc) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
if now - state.last_drop_time < 1000 do
|
||||
new_drops = state.drops_per_second + 1
|
||||
threshold = 16 # 32 for DC mode
|
||||
|
||||
if new_drops >= threshold do
|
||||
# TODO: Set monitored flag or DC
|
||||
Logger.warning("[AntiCheat] High drop rate for char #{state.character_id}: #{new_drops}/sec")
|
||||
end
|
||||
|
||||
%{state | drops_per_second: new_drops, last_drop_time: now}
|
||||
else
|
||||
%{state | drops_per_second: 0, last_drop_time: now}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_msg_rate(state) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
if now - state.last_msg_time < 1000 do
|
||||
new_msgs = state.msgs_per_second + 1
|
||||
|
||||
if new_msgs > 10 do
|
||||
Logger.warning("[AntiCheat] High message rate for char #{state.character_id}: #{new_msgs}/sec")
|
||||
end
|
||||
|
||||
%{state | msgs_per_second: new_msgs, last_msg_time: now}
|
||||
else
|
||||
%{state | msgs_per_second: 0, last_msg_time: now}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_tick_update(state, new_tick) do
|
||||
if new_tick <= state.last_tick_count do
|
||||
# Packet spamming detected
|
||||
new_same = state.tick_same + 1
|
||||
|
||||
if new_same >= 5 do
|
||||
# TODO: Close session
|
||||
Logger.warning("[AntiCheat] Packet spamming detected for char #{state.character_id}")
|
||||
end
|
||||
|
||||
%{state | tick_same: new_same, last_tick_count: new_tick}
|
||||
else
|
||||
%{state | tick_same: 0, last_tick_count: new_tick}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_summon_timing(state) do
|
||||
new_count = state.num_sequential_summon_attack + 1
|
||||
now = System.monotonic_time(:millisecond)
|
||||
time_diff = now - state.summon_summon_time
|
||||
|
||||
# Allow 1 summon attack per second + 1
|
||||
allowed = div(time_diff, 1000) + 1
|
||||
|
||||
if allowed < new_count do
|
||||
new_state = do_register_offense(state, :fast_summon_attack, nil)
|
||||
{false, %{new_state | num_sequential_summon_attack: new_count}}
|
||||
else
|
||||
{true, %{state | num_sequential_summon_attack: new_count}}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_familiar_timing(state) do
|
||||
new_count = state.num_sequential_familiar_attack + 1
|
||||
now = System.monotonic_time(:millisecond)
|
||||
time_diff = now - state.familiar_summon_time
|
||||
|
||||
# Allow 1 familiar attack per 600ms + 1
|
||||
allowed = div(time_diff, 600) + 1
|
||||
|
||||
if allowed < new_count do
|
||||
new_state = do_register_offense(state, :fast_summon_attack, nil)
|
||||
{false, %{new_state | num_sequential_familiar_attack: new_count}}
|
||||
else
|
||||
{true, %{state | num_sequential_familiar_attack: new_count}}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_monster_movement(state, position) do
|
||||
if state.last_monster_move == position do
|
||||
new_count = state.monster_move_count + 1
|
||||
|
||||
state = if new_count > 10 do
|
||||
do_register_offense(state, :move_monsters, "Position: #{inspect(position)}")
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
%{state | monster_move_count: 0}
|
||||
else
|
||||
%{state |
|
||||
last_monster_move: position,
|
||||
monster_move_count: 1
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp offense_name(offense) do
|
||||
offense
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.capitalize()
|
||||
end
|
||||
end
|
||||
36
lib/odinsea/anticheat/supervisor.ex
Normal file
36
lib/odinsea/anticheat/supervisor.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Odinsea.AntiCheat.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for the Anti-Cheat system.
|
||||
|
||||
Manages:
|
||||
- AutobanManager (singleton)
|
||||
- CheatTracker processes (dynamic)
|
||||
- LieDetector timeout handler
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
def start_link(init_arg) do
|
||||
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
# Autoban manager (singleton)
|
||||
Odinsea.AntiCheat.AutobanManager,
|
||||
|
||||
# Lie detector timeout handler
|
||||
Odinsea.AntiCheat.LieDetector.TimeoutHandler,
|
||||
|
||||
# Dynamic supervisor for per-character cheat trackers
|
||||
{DynamicSupervisor,
|
||||
name: Odinsea.CheatTrackerSupervisor,
|
||||
strategy: :one_for_one,
|
||||
max_restarts: 1000,
|
||||
max_seconds: 60}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_all)
|
||||
end
|
||||
end
|
||||
438
lib/odinsea/anticheat/validator.ex
Normal file
438
lib/odinsea/anticheat/validator.ex
Normal file
@@ -0,0 +1,438 @@
|
||||
defmodule Odinsea.AntiCheat.Validator do
|
||||
@moduledoc """
|
||||
Validation functions for anti-cheat detection.
|
||||
|
||||
Ported from: handling.channel.handler.DamageParse.java
|
||||
|
||||
This module provides validation for:
|
||||
- Damage validation (checking against calculated max damage)
|
||||
- Movement validation (speed hacking detection)
|
||||
- Item validation (dupe detection, unavailable items)
|
||||
- EXP validation (leveling too fast)
|
||||
- Attack validation (skill timing, bullet count)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.AntiCheat.CheatTracker
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Constants.Game
|
||||
|
||||
# Maximum damage cap (from Plugin.java DamageCap)
|
||||
@damage_cap 9_999_999
|
||||
|
||||
# Maximum distance for attacking (squared, for distance check)
|
||||
@max_attack_distance_sq 500_000
|
||||
|
||||
# Maximum movement speed
|
||||
@max_movement_speed 400
|
||||
|
||||
# Maximum jump height
|
||||
@max_jump_height 200
|
||||
|
||||
# =============================================================================
|
||||
# Damage Validation
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Validates damage dealt to a monster.
|
||||
|
||||
Returns {:ok, validated_damage} or {:error, reason}
|
||||
"""
|
||||
def validate_damage(character_id, damage, expected_max, monster_id, skill_id) do
|
||||
# Check if damage exceeds expected max
|
||||
state = %{character_id: character_id}
|
||||
|
||||
# Check for high damage
|
||||
state = if damage > expected_max do
|
||||
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
|
||||
CheatTracker.register_offense(character_id, :high_damage, param)
|
||||
|
||||
# Check for same damage (potential damage hack)
|
||||
CheatTracker.check_same_damage(character_id, damage, expected_max)
|
||||
|
||||
state
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
# Check for damage exceeding 2x expected (HIGH_DAMAGE_2)
|
||||
state = if damage > expected_max * 2 do
|
||||
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
|
||||
CheatTracker.register_offense(character_id, :high_damage_2, param)
|
||||
|
||||
# Cap the damage
|
||||
capped_damage = trunc(expected_max * 2)
|
||||
|
||||
{:ok, capped_damage}
|
||||
else
|
||||
{:ok, damage}
|
||||
end
|
||||
|
||||
# Check against global damage cap
|
||||
state = if damage > @damage_cap do
|
||||
CheatTracker.register_offense(character_id, :exceed_damage_cap,
|
||||
"Damage: #{damage}, Cap: #{@damage_cap}")
|
||||
{:ok, @damage_cap}
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates magic damage dealt to a monster.
|
||||
"""
|
||||
def validate_magic_damage(character_id, damage, expected_max, monster_id, skill_id) do
|
||||
# Check for high magic damage
|
||||
if damage > expected_max do
|
||||
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
|
||||
CheatTracker.register_offense(character_id, :high_damage_magic, param)
|
||||
|
||||
# Check for same damage
|
||||
CheatTracker.check_same_damage(character_id, damage, expected_max)
|
||||
end
|
||||
|
||||
# Check for damage exceeding 2x expected (HIGH_DAMAGE_MAGIC_2)
|
||||
if damage > expected_max * 2 do
|
||||
param = build_damage_param(damage, expected_max, monster_id, skill_id, character_id)
|
||||
CheatTracker.register_offense(character_id, :high_damage_magic_2, param)
|
||||
|
||||
# Cap the damage
|
||||
{:ok, trunc(expected_max * 2)}
|
||||
else
|
||||
{:ok, damage}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates maximum weapon damage per hit for validation.
|
||||
|
||||
Ported from: DamageParse.CalculateMaxWeaponDamagePerHit()
|
||||
"""
|
||||
def calculate_max_weapon_damage(character, monster, attack_skill) do
|
||||
# Base damage calculation
|
||||
base_damage = Character.get_stat(character, :max_base_damage) || 100
|
||||
|
||||
# Apply skill multipliers
|
||||
damage = if attack_skill && attack_skill > 0 do
|
||||
skill_damage = Game.get_skill_damage(attack_skill)
|
||||
base_damage * (skill_damage / 100.0)
|
||||
else
|
||||
base_damage
|
||||
end
|
||||
|
||||
# Apply monster defense
|
||||
# pdr_rate = Map.get(monster, :pdr_rate, 0)
|
||||
# damage = damage * (1 - pdr_rate / 100.0)
|
||||
|
||||
# Apply boss damage modifier if monster is boss
|
||||
# damage = if Map.get(monster, :is_boss, false) do
|
||||
# boss_dam_r = Character.get_stat(character, :bossdam_r) || 0
|
||||
# damage * (1 + boss_dam_r / 100.0)
|
||||
# else
|
||||
# damage
|
||||
# end
|
||||
|
||||
# Apply damage rate
|
||||
# dam_r = Character.get_stat(character, :dam_r) || 100
|
||||
# damage = damage * (dam_r / 100.0)
|
||||
|
||||
trunc(max(damage, 1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates maximum magic damage per hit for validation.
|
||||
|
||||
Ported from: DamageParse.CalculateMaxMagicDamagePerHit()
|
||||
"""
|
||||
def calculate_max_magic_damage(character, monster, attack_skill) do
|
||||
# Base magic damage calculation
|
||||
base_damage = Character.get_stat(character, :max_base_damage) || 100
|
||||
|
||||
# Magic has different multipliers
|
||||
damage = if attack_skill && attack_skill > 0 do
|
||||
skill_damage = Game.get_skill_damage(attack_skill)
|
||||
base_damage * (skill_damage / 100.0) * 1.5
|
||||
else
|
||||
base_damage * 1.5
|
||||
end
|
||||
|
||||
# Apply monster magic defense
|
||||
# mdr_rate = Map.get(monster, :mdr_rate, 0)
|
||||
# damage = damage * (1 - mdr_rate / 100.0)
|
||||
|
||||
trunc(max(damage, 1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if attack is at valid range.
|
||||
"""
|
||||
def validate_attack_range(character_id, attacker_pos, target_pos, skill_id) do
|
||||
# Calculate distance
|
||||
distance_sq = calculate_distance_sq(attacker_pos, target_pos)
|
||||
|
||||
# Get expected range for skill
|
||||
expected_range = Game.get_attack_range(skill_id)
|
||||
|
||||
if distance_sq > expected_range * expected_range do
|
||||
param = "Distance: #{distance_sq}, Expected: #{expected_range * expected_range}, Skill: #{skill_id}"
|
||||
CheatTracker.register_offense(character_id, :attack_faraway_monster, param)
|
||||
{:error, :out_of_range}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Attack Validation
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Validates attack count matches skill expectations.
|
||||
"""
|
||||
def validate_attack_count(character_id, skill_id, hits, targets, expected_hits, expected_targets) do
|
||||
# Skip certain skills that have special handling
|
||||
if skill_id in [4211006, 3221007, 23121003, 1311001] do
|
||||
:ok
|
||||
else
|
||||
# Check hits
|
||||
if hits > expected_hits do
|
||||
CheatTracker.register_offense(character_id, :mismatching_bulletcount,
|
||||
"Hits: #{hits}, Expected: #{expected_hits}")
|
||||
{:error, :invalid_hits}
|
||||
else
|
||||
# Check targets
|
||||
if targets > expected_targets do
|
||||
CheatTracker.register_offense(character_id, :mismatching_bulletcount,
|
||||
"Targets: #{targets}, Expected: #{expected_targets}")
|
||||
{:error, :invalid_targets}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the character is alive before attacking.
|
||||
"""
|
||||
def validate_alive(character_id, is_alive) do
|
||||
if not is_alive do
|
||||
CheatTracker.register_offense(character_id, :attacking_while_dead, nil)
|
||||
{:error, :dead}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates skill usage in specific maps (e.g., Mu Lung, Pyramid).
|
||||
"""
|
||||
def validate_skill_map(character_id, skill_id, map_id) do
|
||||
# Check Mu Lung skills
|
||||
if Game.is_mulung_skill?(skill_id) do
|
||||
if div(map_id, 10000) != 92502 do
|
||||
# Using Mu Lung skill outside dojo
|
||||
{:error, :wrong_map}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
# Check Pyramid skills
|
||||
if Game.is_pyramid_skill?(skill_id) do
|
||||
if div(map_id, 1000000) != 926 do
|
||||
# Using Pyramid skill outside pyramid
|
||||
{:error, :wrong_map}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Movement Validation
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Validates player movement for speed hacking.
|
||||
|
||||
Returns :ok if valid, or {:error, reason} if suspicious.
|
||||
"""
|
||||
def validate_movement(character_id, old_pos, new_pos, time_diff_ms) do
|
||||
# Calculate distance
|
||||
distance = calculate_distance(old_pos, new_pos)
|
||||
|
||||
# Calculate speed
|
||||
if time_diff_ms > 0 do
|
||||
speed = distance / (time_diff_ms / 1000.0)
|
||||
|
||||
# Check if speed exceeds maximum
|
||||
if speed > @max_movement_speed do
|
||||
# Could be speed hacking or lag
|
||||
# Only flag if significantly over
|
||||
if speed > @max_movement_speed * 1.5 do
|
||||
Logger.warning("[AntiCheat] Speed hack suspected for char #{character_id}: #{speed} px/s")
|
||||
# TODO: Add to offense tracking when FAST_MOVE offense is enabled
|
||||
{:error, :speed_exceeded}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
# Instant movement - check distance
|
||||
if distance > @max_movement_speed do
|
||||
{:error, :teleport_detected}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates jump height for high jump detection.
|
||||
"""
|
||||
def validate_jump(character_id, y_delta) do
|
||||
# Check if jump exceeds maximum
|
||||
if y_delta < -@max_jump_height do
|
||||
CheatTracker.register_offense(character_id, :high_jump,
|
||||
"Jump: #{y_delta}, Max: #{@max_jump_height}")
|
||||
{:error, :high_jump}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates portal usage distance.
|
||||
"""
|
||||
def validate_portal_distance(character_id, player_pos, portal_pos) do
|
||||
distance_sq = calculate_distance_sq(player_pos, portal_pos)
|
||||
max_portal_distance_sq = 200 * 200 # 200 pixels
|
||||
|
||||
if distance_sq > max_portal_distance_sq do
|
||||
CheatTracker.register_offense(character_id, :using_faraway_portal,
|
||||
"Distance: #{:math.sqrt(distance_sq)}")
|
||||
{:error, :too_far}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Item Validation
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Validates item usage (checks if item is available to character).
|
||||
"""
|
||||
def validate_item_usage(character_id, item_id, inventory) do
|
||||
# Check if item exists in inventory
|
||||
has_item = Enum.any?(inventory, fn item ->
|
||||
Map.get(item, :item_id) == item_id
|
||||
end)
|
||||
|
||||
if not has_item do
|
||||
CheatTracker.register_offense(character_id, :using_unavailable_item,
|
||||
"ItemID: #{item_id}")
|
||||
{:error, :item_not_found}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates item quantity (dupe detection).
|
||||
"""
|
||||
def validate_item_quantity(character_id, item_id, quantity, expected_max) do
|
||||
if quantity > expected_max do
|
||||
# Potential dupe
|
||||
Logger.warning("[AntiCheat] Suspicious item quantity for char #{character_id}: #{item_id} x#{quantity}")
|
||||
{:error, :quantity_exceeded}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates meso explosion (checks if meso exists on map).
|
||||
"""
|
||||
def validate_meso_explosion(character_id, map_item) do
|
||||
if map_item == nil do
|
||||
CheatTracker.register_offense(character_id, :exploding_nonexistant, nil)
|
||||
{:error, :no_meso}
|
||||
else
|
||||
meso = Map.get(map_item, :meso, 0)
|
||||
if meso <= 0 do
|
||||
CheatTracker.register_offense(character_id, :etc_explosion, nil)
|
||||
{:error, :invalid_meso}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# EXP Validation
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Validates EXP gain rate.
|
||||
"""
|
||||
def validate_exp_gain(character_id, exp_gained, time_since_last_gain_ms) do
|
||||
# Calculate EXP per minute
|
||||
if time_since_last_gain_ms > 0 do
|
||||
exp_per_minute = exp_gained / (time_since_last_gain_ms / 60000.0)
|
||||
|
||||
# Maximum reasonable EXP per minute (varies by level, this is a rough check)
|
||||
max_exp_per_minute = 10_000_000
|
||||
|
||||
if exp_per_minute > max_exp_per_minute do
|
||||
Logger.warning("[AntiCheat] High EXP rate for char #{character_id}: #{exp_per_minute}/min")
|
||||
# TODO: Add to offense tracking
|
||||
{:warning, :high_exp_rate}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates level progression (checks for impossible jumps).
|
||||
"""
|
||||
def validate_level_progression(old_level, new_level) do
|
||||
max_level_jump = 5
|
||||
|
||||
if new_level - old_level > max_level_jump do
|
||||
{:error, :impossible_level_jump}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
defp build_damage_param(damage, expected, monster_id, skill_id, character_id) do
|
||||
"[Damage: #{damage}, Expected: #{expected}, Mob: #{monster_id}] [Skill: #{skill_id}]"
|
||||
end
|
||||
|
||||
defp calculate_distance_sq(pos1, pos2) do
|
||||
dx = Map.get(pos1, :x, 0) - Map.get(pos2, :x, 0)
|
||||
dy = Map.get(pos1, :y, 0) - Map.get(pos2, :y, 0)
|
||||
dx * dx + dy * dy
|
||||
end
|
||||
|
||||
defp calculate_distance(pos1, pos2) do
|
||||
:math.sqrt(calculate_distance_sq(pos1, pos2))
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,32 @@ defmodule Odinsea.Application do
|
||||
Odinsea.Game.ItemInfo,
|
||||
Odinsea.Game.MapFactory,
|
||||
Odinsea.Game.LifeFactory,
|
||||
Odinsea.Game.DropTable,
|
||||
Odinsea.Game.SkillFactory,
|
||||
Odinsea.Game.ReactorFactory,
|
||||
Odinsea.Game.Quest,
|
||||
|
||||
# Cash Shop data provider
|
||||
Odinsea.Shop.CashItemFactory,
|
||||
|
||||
# MTS (Maple Trading System)
|
||||
Odinsea.Shop.MTS,
|
||||
|
||||
# Scripting system (must be before game servers)
|
||||
Odinsea.Scripting.Supervisor,
|
||||
|
||||
# Timer system (before game servers)
|
||||
Odinsea.Game.Timer.WorldTimer,
|
||||
Odinsea.Game.Timer.MapTimer,
|
||||
Odinsea.Game.Timer.BuffTimer,
|
||||
Odinsea.Game.Timer.EventTimer,
|
||||
Odinsea.Game.Timer.CloneTimer,
|
||||
Odinsea.Game.Timer.EtcTimer,
|
||||
Odinsea.Game.Timer.CheatTimer,
|
||||
Odinsea.Game.Timer.PingTimer,
|
||||
Odinsea.Game.Timer.RedisTimer,
|
||||
Odinsea.Game.Timer.EMTimer,
|
||||
Odinsea.Game.Timer.GlobalTimer,
|
||||
|
||||
# Registry for player lookups
|
||||
{Registry, keys: :unique, name: Odinsea.PlayerRegistry},
|
||||
|
||||
@@ -115,6 +115,75 @@ defmodule Odinsea.Channel.Client do
|
||||
cp_public_npc = Opcodes.cp_public_npc()
|
||||
cp_use_scripted_npc_item = Opcodes.cp_use_scripted_npc_item()
|
||||
|
||||
# Mob opcodes
|
||||
cp_move_life = Opcodes.cp_move_life()
|
||||
cp_auto_aggro = Opcodes.cp_auto_aggro()
|
||||
cp_mob_skill_delay_end = Opcodes.cp_mob_skill_delay_end()
|
||||
cp_mob_bomb = Opcodes.cp_mob_bomb()
|
||||
|
||||
# Summon opcodes
|
||||
cp_move_summon = Opcodes.cp_move_summon()
|
||||
cp_summon_attack = Opcodes.cp_summon_attack()
|
||||
cp_damage_summon = Opcodes.cp_damage_summon()
|
||||
cp_sub_summon = Opcodes.cp_sub_summon()
|
||||
cp_remove_summon = Opcodes.cp_remove_summon()
|
||||
cp_move_dragon = Opcodes.cp_move_dragon()
|
||||
|
||||
# Player operations
|
||||
cp_note_action = Opcodes.cp_note_action()
|
||||
cp_give_fame = Opcodes.cp_give_fame()
|
||||
cp_use_door = Opcodes.cp_use_door()
|
||||
cp_use_mech_door = Opcodes.cp_use_mech_door()
|
||||
cp_transform_player = Opcodes.cp_transform_player()
|
||||
cp_damage_reactor = Opcodes.cp_damage_reactor()
|
||||
cp_touch_reactor = Opcodes.cp_touch_reactor()
|
||||
cp_coconut = Opcodes.cp_coconut()
|
||||
cp_follow_request = Opcodes.cp_follow_request()
|
||||
cp_follow_reply = Opcodes.cp_follow_reply()
|
||||
cp_ring_action = Opcodes.cp_ring_action()
|
||||
cp_solomon = Opcodes.cp_solomon()
|
||||
cp_gach_exp = Opcodes.cp_gach_exp()
|
||||
cp_report = Opcodes.cp_report()
|
||||
cp_enter_pvp = Opcodes.cp_enter_pvp()
|
||||
cp_leave_pvp = Opcodes.cp_leave_pvp()
|
||||
cp_pvp_respawn = Opcodes.cp_pvp_respawn()
|
||||
cp_pvp_attack = Opcodes.cp_pvp_attack()
|
||||
|
||||
# UI opcodes
|
||||
cp_cygnus_summon = Opcodes.cp_cygnus_summon()
|
||||
cp_game_poll = Opcodes.cp_game_poll()
|
||||
cp_ship_object = Opcodes.cp_ship_object()
|
||||
|
||||
# BBS
|
||||
cp_bbs_operation = Opcodes.cp_bbs_operation()
|
||||
|
||||
# Duey
|
||||
cp_duey_action = Opcodes.cp_duey_action()
|
||||
|
||||
# Monster Carnival
|
||||
cp_monster_carnival = Opcodes.cp_monster_carnival()
|
||||
|
||||
# Alliance
|
||||
cp_alliance_operation = Opcodes.cp_alliance_operation()
|
||||
cp_deny_alliance_request = Opcodes.cp_deny_alliance_request()
|
||||
|
||||
# Item Maker / Crafting
|
||||
cp_item_maker = Opcodes.cp_item_maker()
|
||||
cp_use_recipe = Opcodes.cp_use_recipe()
|
||||
cp_make_extractor = Opcodes.cp_make_extractor()
|
||||
cp_use_bag = Opcodes.cp_use_bag()
|
||||
cp_start_harvest = Opcodes.cp_start_harvest()
|
||||
cp_stop_harvest = Opcodes.cp_stop_harvest()
|
||||
cp_profession_info = Opcodes.cp_profession_info()
|
||||
cp_craft_effect = Opcodes.cp_craft_effect()
|
||||
cp_craft_make = Opcodes.cp_craft_make()
|
||||
cp_craft_done = Opcodes.cp_craft_done()
|
||||
cp_use_pot = Opcodes.cp_use_pot()
|
||||
cp_clear_pot = Opcodes.cp_clear_pot()
|
||||
cp_feed_pot = Opcodes.cp_feed_pot()
|
||||
cp_cure_pot = Opcodes.cp_cure_pot()
|
||||
cp_reward_pot = Opcodes.cp_reward_pot()
|
||||
|
||||
case opcode do
|
||||
# Chat handlers
|
||||
^cp_general_chat ->
|
||||
@@ -277,6 +346,219 @@ defmodule Odinsea.Channel.Client do
|
||||
Handler.NPC.handle_use_scripted_npc_item(packet, self())
|
||||
state
|
||||
|
||||
# Mob handlers
|
||||
^cp_move_life ->
|
||||
Handler.Mob.handle_mob_move(packet, self())
|
||||
state
|
||||
|
||||
^cp_auto_aggro ->
|
||||
Handler.Mob.handle_auto_aggro(packet, self())
|
||||
state
|
||||
|
||||
^cp_mob_skill_delay_end ->
|
||||
Handler.Mob.handle_mob_skill_delay_end(packet, self())
|
||||
state
|
||||
|
||||
^cp_mob_bomb ->
|
||||
Handler.Mob.handle_mob_bomb(packet, self())
|
||||
state
|
||||
|
||||
# Summon handlers
|
||||
^cp_move_summon ->
|
||||
Handler.Summon.handle_move_summon(packet, self())
|
||||
state
|
||||
|
||||
^cp_summon_attack ->
|
||||
Handler.Summon.handle_summon_attack(packet, self())
|
||||
state
|
||||
|
||||
^cp_damage_summon ->
|
||||
Handler.Summon.handle_damage_summon(packet, self())
|
||||
state
|
||||
|
||||
^cp_sub_summon ->
|
||||
Handler.Summon.handle_sub_summon(packet, self())
|
||||
state
|
||||
|
||||
^cp_remove_summon ->
|
||||
Handler.Summon.handle_remove_summon(packet, self())
|
||||
state
|
||||
|
||||
^cp_move_dragon ->
|
||||
Handler.Summon.handle_move_dragon(packet, self())
|
||||
state
|
||||
|
||||
# Player handlers
|
||||
^cp_note_action ->
|
||||
Handler.Players.handle_note(packet, self())
|
||||
state
|
||||
|
||||
^cp_give_fame ->
|
||||
Handler.Players.handle_give_fame(packet, self())
|
||||
state
|
||||
|
||||
^cp_use_door ->
|
||||
Handler.Players.handle_use_door(packet, self())
|
||||
state
|
||||
|
||||
^cp_use_mech_door ->
|
||||
Handler.Players.handle_use_mech_door(packet, self())
|
||||
state
|
||||
|
||||
^cp_transform_player ->
|
||||
Handler.Players.handle_transform_player(packet, self())
|
||||
state
|
||||
|
||||
^cp_damage_reactor ->
|
||||
Handler.Players.handle_hit_reactor(packet, self())
|
||||
state
|
||||
|
||||
^cp_touch_reactor ->
|
||||
Handler.Players.handle_touch_reactor(packet, self())
|
||||
state
|
||||
|
||||
^cp_coconut ->
|
||||
Handler.Players.handle_hit_coconut(packet, self())
|
||||
state
|
||||
|
||||
^cp_follow_request ->
|
||||
Handler.Players.handle_follow_request(packet, self())
|
||||
state
|
||||
|
||||
^cp_follow_reply ->
|
||||
Handler.Players.handle_follow_reply(packet, self())
|
||||
state
|
||||
|
||||
^cp_ring_action ->
|
||||
Handler.Players.handle_ring_action(packet, self())
|
||||
state
|
||||
|
||||
^cp_solomon ->
|
||||
Handler.Players.handle_solomon(packet, self())
|
||||
state
|
||||
|
||||
^cp_gach_exp ->
|
||||
Handler.Players.handle_gach_exp(packet, self())
|
||||
state
|
||||
|
||||
^cp_report ->
|
||||
Handler.Players.handle_report(packet, self())
|
||||
state
|
||||
|
||||
^cp_enter_pvp ->
|
||||
Handler.Players.handle_enter_pvp(packet, self())
|
||||
state
|
||||
|
||||
^cp_leave_pvp ->
|
||||
Handler.Players.handle_leave_pvp(packet, self())
|
||||
state
|
||||
|
||||
^cp_pvp_respawn ->
|
||||
Handler.Players.handle_respawn_pvp(packet, self())
|
||||
state
|
||||
|
||||
^cp_pvp_attack ->
|
||||
Handler.Players.handle_attack_pvp(packet, self())
|
||||
state
|
||||
|
||||
# UI handlers
|
||||
^cp_cygnus_summon ->
|
||||
Handler.UI.handle_cygnus_summon(packet, self())
|
||||
state
|
||||
|
||||
^cp_game_poll ->
|
||||
Handler.UI.handle_game_poll(packet, self())
|
||||
state
|
||||
|
||||
^cp_ship_object ->
|
||||
Handler.UI.handle_ship_object(packet, self())
|
||||
state
|
||||
|
||||
# BBS handler
|
||||
^cp_bbs_operation ->
|
||||
Handler.BBS.handle_bbs_operation(packet, self())
|
||||
state
|
||||
|
||||
# Duey handler
|
||||
^cp_duey_action ->
|
||||
Handler.Duey.handle_duey_operation(packet, self())
|
||||
state
|
||||
|
||||
# Monster Carnival handler
|
||||
^cp_monster_carnival ->
|
||||
Handler.MonsterCarnival.handle_monster_carnival(packet, self())
|
||||
state
|
||||
|
||||
# Alliance handlers
|
||||
^cp_alliance_operation ->
|
||||
Handler.Alliance.handle_alliance(packet, self())
|
||||
state
|
||||
|
||||
^cp_deny_alliance_request ->
|
||||
Handler.Alliance.handle_deny_invite(packet, self())
|
||||
state
|
||||
|
||||
# Item Maker handlers
|
||||
^cp_item_maker ->
|
||||
Handler.ItemMaker.handle_item_maker(packet, self())
|
||||
state
|
||||
|
||||
^cp_use_recipe ->
|
||||
Handler.ItemMaker.handle_use_recipe(packet, self())
|
||||
state
|
||||
|
||||
^cp_make_extractor ->
|
||||
Handler.ItemMaker.handle_make_extractor(packet, self())
|
||||
state
|
||||
|
||||
^cp_use_bag ->
|
||||
Handler.ItemMaker.handle_use_bag(packet, self())
|
||||
state
|
||||
|
||||
^cp_start_harvest ->
|
||||
Handler.ItemMaker.handle_start_harvest(packet, self())
|
||||
state
|
||||
|
||||
^cp_stop_harvest ->
|
||||
Handler.ItemMaker.handle_stop_harvest(packet, self())
|
||||
state
|
||||
|
||||
^cp_profession_info ->
|
||||
Handler.ItemMaker.handle_profession_info(packet, self())
|
||||
state
|
||||
|
||||
^cp_craft_effect ->
|
||||
Handler.ItemMaker.handle_craft_effect(packet, self())
|
||||
state
|
||||
|
||||
^cp_craft_make ->
|
||||
Handler.ItemMaker.handle_craft_make(packet, self())
|
||||
state
|
||||
|
||||
^cp_craft_done ->
|
||||
Handler.ItemMaker.handle_craft_complete(packet, self())
|
||||
state
|
||||
|
||||
^cp_use_pot ->
|
||||
Handler.ItemMaker.handle_use_pot(packet, self())
|
||||
state
|
||||
|
||||
^cp_clear_pot ->
|
||||
Handler.ItemMaker.handle_clear_pot(packet, self())
|
||||
state
|
||||
|
||||
^cp_feed_pot ->
|
||||
Handler.ItemMaker.handle_feed_pot(packet, self())
|
||||
state
|
||||
|
||||
^cp_cure_pot ->
|
||||
Handler.ItemMaker.handle_cure_pot(packet, self())
|
||||
state
|
||||
|
||||
^cp_reward_pot ->
|
||||
Handler.ItemMaker.handle_reward_pot(packet, self())
|
||||
state
|
||||
|
||||
_ ->
|
||||
Logger.debug("Unhandled channel opcode: 0x#{Integer.to_string(opcode, 16)}")
|
||||
state
|
||||
|
||||
282
lib/odinsea/channel/handler/alliance.ex
Normal file
282
lib/odinsea/channel/handler/alliance.ex
Normal file
@@ -0,0 +1,282 @@
|
||||
defmodule Odinsea.Channel.Handler.Alliance do
|
||||
@moduledoc """
|
||||
Handles Guild Alliance operations.
|
||||
|
||||
Ported from: src/handling/channel/handler/AllianceHandler.java
|
||||
|
||||
Guild alliances allow multiple guilds to:
|
||||
- Share a common chat channel
|
||||
- Display alliance information
|
||||
- Coordinate activities
|
||||
|
||||
## Operations
|
||||
- 1: Load alliance info
|
||||
- 2: Leave alliance
|
||||
- 3: Invite guild to alliance
|
||||
- 4: Accept alliance invitation
|
||||
- 6: Expel guild from alliance
|
||||
- 7: Change alliance leader
|
||||
- 8: Update alliance titles (ranks)
|
||||
- 9: Change member guild rank
|
||||
- 10: Update alliance notice
|
||||
- 22: Deny alliance invitation
|
||||
|
||||
## Main Handlers
|
||||
- handle_alliance/2 - All alliance operations
|
||||
- handle_deny_invite/2 - Deny alliance invitation
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.World.Guild
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Alliance Operations
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles all alliance operations (CP_ALLIANCE_OPERATION / 0xBA).
|
||||
|
||||
Reference: AllianceHandler.HandleAlliance()
|
||||
"""
|
||||
def handle_alliance(packet, client_pid, denied \\ false) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
guild_id = char_state.guild_id
|
||||
|
||||
# Check if in guild
|
||||
if guild_id <= 0 do
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
else
|
||||
# Get guild info
|
||||
# guild = World.Guild.get_guild(guild_id)
|
||||
|
||||
op = In.decode_byte(packet)
|
||||
|
||||
# Handle deny separately
|
||||
if op == 22 do
|
||||
handle_deny_invite(client_pid, character_id, char_state, guild_id)
|
||||
else
|
||||
handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
|
||||
end
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle alliance: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Individual Operations
|
||||
# ============================================================================
|
||||
|
||||
# Operation 1: Load alliance info
|
||||
defp handle_alliance_op(1, _packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
|
||||
if alliance_id > 0 do
|
||||
# TODO: Get alliance info from World.Alliance
|
||||
# packets = World.Alliance.get_alliance_info(alliance_id, false)
|
||||
# Enum.each(packets, fn packet -> send(client_pid, {:send_packet, packet}) end)
|
||||
|
||||
Logger.debug("Alliance load: alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 2: Leave alliance / Operation 6: Expel guild
|
||||
defp handle_alliance_op(op, packet, client_pid, character_id, char_state, guild_id)
|
||||
when op in [2, 6] do
|
||||
alliance_id = char_state.alliance_id
|
||||
guild_rank = char_state.guild_rank
|
||||
|
||||
# Get target guild ID
|
||||
target_guild_id = if op == 6 and byte_size(packet.data) >= 4 do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
guild_id
|
||||
end
|
||||
|
||||
# Permission check: alliance rank <= 2, or own guild
|
||||
if guild_rank <= 2 or target_guild_id == guild_id do
|
||||
# TODO: Remove guild from alliance
|
||||
# World.Alliance.remove_guild_from_alliance(alliance_id, target_guild_id, target_guild_id != guild_id)
|
||||
|
||||
Logger.debug("Alliance remove: guild #{target_guild_id} from alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 3: Invite guild to alliance
|
||||
defp handle_alliance_op(3, packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
alliance_rank = char_state.alliance_rank
|
||||
|
||||
# Get guild leader name to invite
|
||||
target_leader_name = In.decode_string(packet)
|
||||
|
||||
# Only alliance leader (rank 1) can invite
|
||||
if alliance_rank == 1 do
|
||||
# TODO: Get target guild leader ID
|
||||
# target_leader_id = World.Guild.get_guild_leader(target_leader_name)
|
||||
|
||||
# TODO: Get target character
|
||||
# target_char = ChannelServer.get_player_storage().get_character_by_id(target_leader_id)
|
||||
|
||||
# TODO: Check if can invite
|
||||
# if World.Alliance.can_invite(alliance_id) do
|
||||
# # Send invite
|
||||
# target_char.client.send_packet(Packets.alliance_invite(alliance_name, character_id))
|
||||
# World.Guild.set_invited_id(target_char.guild_id, alliance_id)
|
||||
# end
|
||||
|
||||
Logger.debug("Alliance invite: to leader #{target_leader_name}, alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 4: Accept alliance invitation
|
||||
defp handle_alliance_op(4, _packet, client_pid, character_id, char_state, guild_id) do
|
||||
# Get invited alliance ID
|
||||
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
|
||||
|
||||
# if invited_alliance_id > 0 do
|
||||
# # Add guild to alliance
|
||||
# success = World.Alliance.add_guild_to_alliance(invited_alliance_id, guild_id)
|
||||
# if not success do
|
||||
# # Send error message
|
||||
# end
|
||||
#
|
||||
# # Clear invited ID
|
||||
# World.Guild.set_invited_id(guild_id, 0)
|
||||
# end
|
||||
|
||||
Logger.debug("Alliance accept: guild #{guild_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 7: Change alliance leader
|
||||
defp handle_alliance_op(7, packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
alliance_rank = char_state.alliance_rank
|
||||
|
||||
# Only alliance leader can change leader
|
||||
if alliance_rank == 1 do
|
||||
new_leader_id = In.decode_int(packet)
|
||||
|
||||
# TODO: Change alliance leader
|
||||
# World.Alliance.change_alliance_leader(alliance_id, new_leader_id)
|
||||
|
||||
Logger.debug("Alliance leader change: to #{new_leader_id}, alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 8: Update alliance titles/ranks
|
||||
defp handle_alliance_op(8, packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
alliance_rank = char_state.alliance_rank
|
||||
|
||||
# Only alliance leader can update titles
|
||||
if alliance_rank == 1 do
|
||||
# Read 5 rank titles
|
||||
ranks = Enum.map(1..5, fn _ -> In.decode_string(packet) end)
|
||||
|
||||
# TODO: Update alliance ranks
|
||||
# World.Alliance.update_alliance_ranks(alliance_id, ranks)
|
||||
|
||||
Logger.debug("Alliance ranks update: alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 9: Change member guild rank
|
||||
defp handle_alliance_op(9, packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
alliance_rank = char_state.alliance_rank
|
||||
|
||||
# Alliance rank <= 2 can change ranks
|
||||
if alliance_rank <= 2 do
|
||||
target_guild_id = In.decode_int(packet)
|
||||
new_rank = In.decode_byte(packet)
|
||||
|
||||
# TODO: Change guild rank in alliance
|
||||
# World.Alliance.change_alliance_rank(alliance_id, target_guild_id, new_rank)
|
||||
|
||||
Logger.debug("Alliance rank change: guild #{target_guild_id} to rank #{new_rank}, alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 10: Update alliance notice
|
||||
defp handle_alliance_op(10, packet, client_pid, character_id, char_state, guild_id) do
|
||||
alliance_id = char_state.alliance_id
|
||||
alliance_rank = char_state.alliance_rank
|
||||
|
||||
# Alliance rank <= 2 can update notice
|
||||
if alliance_rank <= 2 do
|
||||
notice = In.decode_string(packet)
|
||||
|
||||
# Check notice length (max 100)
|
||||
if String.length(notice) <= 100 do
|
||||
# TODO: Update alliance notice
|
||||
# World.Alliance.update_alliance_notice(alliance_id, notice)
|
||||
|
||||
Logger.debug("Alliance notice update: alliance #{alliance_id}, character #{character_id}")
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Unknown operation
|
||||
defp handle_alliance_op(op, _packet, _client_pid, character_id, _char_state, guild_id) do
|
||||
Logger.warning("Unknown alliance operation #{op} from character #{character_id}, guild #{guild_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Deny Invite Handler
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles deny alliance invitation (CP_DENY_ALLIANCE_REQUEST / 0xBB).
|
||||
|
||||
Also called when op == 22 in alliance operation.
|
||||
|
||||
Reference: AllianceHandler.DenyInvite()
|
||||
"""
|
||||
def handle_deny_invite(client_pid, character_id, char_state, guild_id) do
|
||||
# Get invited alliance ID
|
||||
# invited_alliance_id = World.Guild.get_invited_id(guild_id)
|
||||
|
||||
# if invited_alliance_id > 0 do
|
||||
# # Get alliance leader
|
||||
# leader_id = World.Alliance.get_alliance_leader(invited_alliance_id)
|
||||
#
|
||||
# if leader_id > 0 do
|
||||
# # Notify leader of rejection
|
||||
# leader = ChannelServer.get_player_storage().get_character_by_id(leader_id)
|
||||
# if leader do
|
||||
# leader.drop_message(5, "#{guild.name} Guild has rejected the Guild Union invitation.")
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# # Clear invited ID
|
||||
# World.Guild.set_invited_id(guild_id, 0)
|
||||
# end
|
||||
|
||||
Logger.debug("Alliance invite denied: guild #{guild_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
254
lib/odinsea/channel/handler/bbs.ex
Normal file
254
lib/odinsea/channel/handler/bbs.ex
Normal file
@@ -0,0 +1,254 @@
|
||||
defmodule Odinsea.Channel.Handler.BBS do
|
||||
@moduledoc """
|
||||
Handles Guild BBS (Bulletin Board System) operations.
|
||||
|
||||
Ported from: src/handling/channel/handler/BBSHandler.java
|
||||
|
||||
The Guild BBS allows guild members to:
|
||||
- Create and edit threads/posts
|
||||
- Reply to threads
|
||||
- Delete threads and replies (with permission checks)
|
||||
- List threads with pagination
|
||||
- View individual threads with replies
|
||||
|
||||
## Main Handlers
|
||||
- handle_bbs_operation/2 - All BBS operations (CRUD)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.World.Guild
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# BBS Operations
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles all BBS operations (CP_BBS_OPERATION / 0xCD).
|
||||
|
||||
Actions:
|
||||
- 0: Create new post / Edit existing post
|
||||
- 1: Delete a thread
|
||||
- 2: List threads (pagination)
|
||||
- 3: Display thread with replies
|
||||
- 4: Add reply to thread
|
||||
- 5: Delete reply from thread
|
||||
|
||||
Reference: BBSHandler.BBSOperation()
|
||||
"""
|
||||
def handle_bbs_operation(packet, client_pid) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
guild_id = char_state.guild_id
|
||||
|
||||
if guild_id <= 0 do
|
||||
Logger.debug("BBS operation rejected: character #{character_id} not in guild")
|
||||
:ok
|
||||
else
|
||||
action = In.decode_byte(packet)
|
||||
handle_bbs_action(action, packet, client_pid, character_id, char_state, guild_id)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle BBS operation: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Individual Actions
|
||||
# ============================================================================
|
||||
|
||||
# Action 0: Create or edit post
|
||||
defp handle_bbs_action(0, packet, client_pid, character_id, char_state, guild_id) do
|
||||
is_edit = In.decode_byte(packet) > 0
|
||||
|
||||
local_thread_id = if is_edit do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
0
|
||||
end
|
||||
|
||||
is_notice = In.decode_byte(packet) > 0
|
||||
title = In.decode_string(packet) |> correct_length(25)
|
||||
text = In.decode_string(packet) |> correct_length(600)
|
||||
icon = In.decode_int(packet)
|
||||
|
||||
# Validate icon
|
||||
valid_icon = validate_icon(icon, character_id)
|
||||
|
||||
if valid_icon do
|
||||
if is_edit do
|
||||
# Edit existing thread
|
||||
edit_thread(guild_id, local_thread_id, title, text, icon, character_id, char_state.guild_rank)
|
||||
else
|
||||
# Create new thread
|
||||
create_thread(guild_id, title, text, icon, is_notice, character_id)
|
||||
end
|
||||
|
||||
# Send updated thread list
|
||||
list_threads(client_pid, guild_id, 0)
|
||||
end
|
||||
|
||||
Logger.debug("BBS create/edit: guild #{guild_id}, edit=#{is_edit}, notice=#{is_notice}, icon=#{icon}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Action 1: Delete thread
|
||||
defp handle_bbs_action(1, packet, client_pid, character_id, char_state, guild_id) do
|
||||
local_thread_id = In.decode_int(packet)
|
||||
|
||||
delete_thread(guild_id, local_thread_id, character_id, char_state.guild_rank)
|
||||
|
||||
Logger.debug("BBS delete thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Action 2: List threads (pagination)
|
||||
defp handle_bbs_action(2, packet, client_pid, character_id, _char_state, guild_id) do
|
||||
start = In.decode_int(packet)
|
||||
|
||||
list_threads(client_pid, guild_id, start * 10)
|
||||
|
||||
Logger.debug("BBS list threads: guild #{guild_id}, start #{start}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Action 3: Display thread
|
||||
defp handle_bbs_action(3, packet, client_pid, character_id, _char_state, guild_id) do
|
||||
local_thread_id = In.decode_int(packet)
|
||||
|
||||
display_thread(client_pid, guild_id, local_thread_id)
|
||||
|
||||
Logger.debug("BBS display thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Action 4: Add reply
|
||||
defp handle_bbs_action(4, packet, client_pid, character_id, _char_state, guild_id) do
|
||||
# Check rate limit (60 seconds between replies)
|
||||
# TODO: Implement rate limiting via CheatTracker
|
||||
|
||||
local_thread_id = In.decode_int(packet)
|
||||
text = In.decode_string(packet) |> correct_length(25)
|
||||
|
||||
add_reply(guild_id, local_thread_id, text, character_id)
|
||||
|
||||
# Refresh thread display
|
||||
display_thread(client_pid, guild_id, local_thread_id)
|
||||
|
||||
Logger.debug("BBS add reply: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Action 5: Delete reply
|
||||
defp handle_bbs_action(5, packet, client_pid, character_id, char_state, guild_id) do
|
||||
local_thread_id = In.decode_int(packet)
|
||||
reply_id = In.decode_int(packet)
|
||||
|
||||
delete_reply(guild_id, local_thread_id, reply_id, character_id, char_state.guild_rank)
|
||||
|
||||
# Refresh thread display
|
||||
display_thread(client_pid, guild_id, local_thread_id)
|
||||
|
||||
Logger.debug("BBS delete reply: guild #{guild_id}, thread #{local_thread_id}, reply #{reply_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Unknown action
|
||||
defp handle_bbs_action(action, _packet, _client_pid, character_id, _char_state, guild_id) do
|
||||
Logger.warning("Unknown BBS action #{action} from character #{character_id}, guild #{guild_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# BBS Backend Operations
|
||||
# ============================================================================
|
||||
|
||||
defp create_thread(guild_id, title, text, icon, is_notice, character_id) do
|
||||
# TODO: Call World.Guild.addBBSThread
|
||||
# Returns: local_thread_id
|
||||
Logger.debug("Create BBS thread: guild #{guild_id}, title '#{title}', character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp edit_thread(guild_id, local_thread_id, title, text, icon, character_id, guild_rank) do
|
||||
# TODO: Call World.Guild.editBBSThread
|
||||
# Permission: thread owner OR guild rank <= 2
|
||||
Logger.debug("Edit BBS thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp delete_thread(guild_id, local_thread_id, character_id, guild_rank) do
|
||||
# TODO: Call World.Guild.deleteBBSThread
|
||||
# Permission: thread owner OR guild rank <= 2 (masters/jr masters)
|
||||
Logger.debug("Delete BBS thread: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp list_threads(client_pid, guild_id, start) do
|
||||
# TODO: Get threads from World.Guild.getBBS
|
||||
# TODO: Build thread list packet
|
||||
# packet = Packets.bbs_thread_list(threads, start)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp display_thread(client_pid, guild_id, local_thread_id) do
|
||||
# TODO: Get thread from World.Guild.getBBS
|
||||
# TODO: Find thread by local_thread_id
|
||||
# TODO: Build show thread packet
|
||||
# packet = Packets.show_thread(thread)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp add_reply(guild_id, local_thread_id, text, character_id) do
|
||||
# TODO: Call World.Guild.addBBSReply
|
||||
Logger.debug("Add BBS reply: guild #{guild_id}, thread #{local_thread_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp delete_reply(guild_id, local_thread_id, reply_id, character_id, guild_rank) do
|
||||
# TODO: Call World.Guild.deleteBBSReply
|
||||
# Permission: reply owner OR guild rank <= 2
|
||||
Logger.debug("Delete BBS reply: guild #{guild_id}, thread #{local_thread_id}, reply #{reply_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
# Truncates string to max length if needed
|
||||
defp correct_length(string, max_size) when is_binary(string) do
|
||||
if String.length(string) > max_size do
|
||||
String.slice(string, 0, max_size)
|
||||
else
|
||||
string
|
||||
end
|
||||
end
|
||||
|
||||
# Validates icon selection
|
||||
# Icons 0x64-0x6A (100-106) require NX items (5290000-5290006)
|
||||
defp validate_icon(icon, character_id) do
|
||||
cond do
|
||||
# NX icons - require specific items
|
||||
icon >= 0x64 and icon <= 0x6A ->
|
||||
# TODO: Check if player has item 5290000 + (icon - 0x64)
|
||||
# For now, allow all
|
||||
true
|
||||
|
||||
# Standard icons (0-2)
|
||||
icon >= 0 and icon <= 2 ->
|
||||
true
|
||||
|
||||
# Invalid icon
|
||||
true ->
|
||||
Logger.warning("Invalid BBS icon #{icon} from character #{character_id}")
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
418
lib/odinsea/channel/handler/buddy.ex
Normal file
418
lib/odinsea/channel/handler/buddy.ex
Normal file
@@ -0,0 +1,418 @@
|
||||
defmodule Odinsea.Channel.Handler.Buddy do
|
||||
@moduledoc """
|
||||
Handles buddy list operations.
|
||||
Ported from src/handling/channel/handler/BuddyListHandler.java
|
||||
|
||||
Manages buddy list add, remove, and accept operations.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Database.Context
|
||||
|
||||
@max_buddy_list 100
|
||||
@default_capacity 20
|
||||
|
||||
@doc """
|
||||
Handles buddy list operations (CP_BUDDYLIST_MODIFY).
|
||||
Ported from BuddyListHandler.BuddyOperation()
|
||||
|
||||
Mode:
|
||||
- 1: Add buddy
|
||||
- 2: Accept buddy
|
||||
- 3: Delete buddy
|
||||
"""
|
||||
def handle_buddy_operation(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
|
||||
{mode, packet} = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
1 -> handle_add_buddy(packet, character, client_state)
|
||||
2 -> handle_accept_buddy(packet, character, client_state)
|
||||
3 -> handle_delete_buddy(packet, character, client_state)
|
||||
_ ->
|
||||
Logger.warning("Unknown buddy operation mode: #{mode}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Buddy operation failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Add Buddy Handler
|
||||
# ============================================================================
|
||||
|
||||
defp handle_add_buddy(packet, character, client_state) do
|
||||
{add_name, packet} = In.decode_string(packet)
|
||||
{group_name, _packet} = In.decode_string(packet)
|
||||
|
||||
# Validate inputs
|
||||
if String.length(add_name) > 13 || String.length(group_name) > 16 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# Check if already in buddy list
|
||||
existing = find_buddy(character.buddies, add_name)
|
||||
|
||||
cond do
|
||||
existing && existing.group == group_name ->
|
||||
# Already in list with same group
|
||||
send_buddy_message(client_state, 11)
|
||||
|
||||
existing && !existing.pending ->
|
||||
# Update group
|
||||
updated_buddies = update_buddy_group(character.buddies, add_name, group_name)
|
||||
Character.update_buddies(character.id, updated_buddies)
|
||||
|
||||
# Send updated buddy list
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
|
||||
send_packet(client_state, buddy_list_packet)
|
||||
|
||||
length(character.buddies) >= @max_buddy_list ->
|
||||
# Buddy list full
|
||||
send_buddy_message(client_state, 11)
|
||||
|
||||
true ->
|
||||
# Try to find and add buddy
|
||||
add_buddy_to_list(add_name, group_name, character, client_state)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_buddy_to_list(add_name, group_name, character, client_state) do
|
||||
# Try to find character on current channel
|
||||
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, add_name) do
|
||||
{:ok, target_character} ->
|
||||
# Check if can add (not GM hiding, not blacklisted)
|
||||
if can_add_buddy?(character, target_character) do
|
||||
# Check target's buddy list capacity
|
||||
if length(target_character.buddies) >= @default_capacity do
|
||||
send_buddy_message(client_state, 12)
|
||||
else
|
||||
# Send buddy request to target
|
||||
send_buddy_request(target_character, character)
|
||||
|
||||
# Add pending buddy to our list
|
||||
buddy = create_buddy_entry(target_character, group_name, -1, true)
|
||||
updated_buddies = character.buddies ++ [buddy]
|
||||
Character.update_buddies(character.id, updated_buddies)
|
||||
|
||||
# Send updated buddy list
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
|
||||
send_packet(client_state, buddy_list_packet)
|
||||
end
|
||||
else
|
||||
send_buddy_message(client_state, 15)
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
# Try to find in database
|
||||
case Context.get_character_by_name(add_name) do
|
||||
nil ->
|
||||
send_buddy_message(client_state, 15)
|
||||
|
||||
target_db ->
|
||||
# Check if target can accept buddy
|
||||
if target_db.gm_level < 3 do
|
||||
# Check buddy capacity in database
|
||||
case get_buddy_count_from_db(target_db.id) do
|
||||
{:ok, count} when count >= @default_capacity ->
|
||||
send_buddy_message(client_state, 12)
|
||||
|
||||
_ ->
|
||||
# Add pending to database
|
||||
insert_pending_buddy(target_db.id, character.id, group_name)
|
||||
|
||||
# Add pending buddy to our list
|
||||
buddy = create_buddy_entry_from_db(target_db, group_name, true)
|
||||
updated_buddies = character.buddies ++ [buddy]
|
||||
Character.update_buddies(character.id, updated_buddies)
|
||||
|
||||
# Send updated buddy list
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
|
||||
send_packet(client_state, buddy_list_packet)
|
||||
end
|
||||
else
|
||||
send_buddy_message(client_state, 15)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Accept Buddy Handler
|
||||
# ============================================================================
|
||||
|
||||
defp handle_accept_buddy(packet, character, client_state) do
|
||||
{other_cid, _packet} = In.decode_int(packet)
|
||||
|
||||
# Find pending buddy
|
||||
buddy = Enum.find(character.buddies, fn b ->
|
||||
b.character_id == other_cid && b.pending
|
||||
end)
|
||||
|
||||
if buddy && length(character.buddies) < @max_buddy_list do
|
||||
# Accept the buddy
|
||||
updated_buddy = %{buddy | pending: false, visible: true, group: "ETC"}
|
||||
|
||||
# Update buddy in list
|
||||
updated_buddies = Enum.map(character.buddies, fn b ->
|
||||
if b.character_id == other_cid do
|
||||
updated_buddy
|
||||
else
|
||||
b
|
||||
end
|
||||
end)
|
||||
|
||||
Character.update_buddies(character.id, updated_buddies)
|
||||
|
||||
# Try to find channel
|
||||
channel = find_buddy_channel(other_cid)
|
||||
|
||||
# Send updated buddy list
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
|
||||
send_packet(client_state, buddy_list_packet)
|
||||
|
||||
# Notify other player if online
|
||||
if channel > 0 do
|
||||
notify_buddy_added(other_cid, character, "ETC")
|
||||
end
|
||||
|
||||
# Update in database
|
||||
accept_buddy_in_db(character.id, other_cid)
|
||||
else
|
||||
send_buddy_message(client_state, 11)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Delete Buddy Handler
|
||||
# ============================================================================
|
||||
|
||||
defp handle_delete_buddy(packet, character, client_state) do
|
||||
{other_cid, _packet} = In.decode_int(packet)
|
||||
|
||||
# Find buddy
|
||||
buddy = Enum.find(character.buddies, fn b -> b.character_id == other_cid end)
|
||||
|
||||
if buddy do
|
||||
# Notify other player if online and visible
|
||||
if buddy.visible do
|
||||
channel = find_buddy_channel(other_cid)
|
||||
if channel > 0 do
|
||||
notify_buddy_removed(other_cid, character.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Remove from our list
|
||||
updated_buddies = Enum.reject(character.buddies, fn b ->
|
||||
b.character_id == other_cid
|
||||
end)
|
||||
|
||||
Character.update_buddies(character.id, updated_buddies)
|
||||
|
||||
# Send updated buddy list
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 18)
|
||||
send_packet(client_state, buddy_list_packet)
|
||||
|
||||
# Remove from database
|
||||
remove_buddy_from_db(character.id, other_cid)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil -> {:error, :no_character}
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_buddy(buddies, name) do
|
||||
name_lower = String.downcase(name)
|
||||
Enum.find(buddies, fn b ->
|
||||
String.downcase(b.name) == name_lower
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_buddy_group(buddies, name, group_name) do
|
||||
Enum.map(buddies, fn b ->
|
||||
if String.downcase(b.name) == String.downcase(name) do
|
||||
%{b | group: group_name}
|
||||
else
|
||||
b
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp can_add_buddy?(character, target) do
|
||||
# Check if target is GM hiding
|
||||
if target.gm? && !character.gm? do
|
||||
false
|
||||
else
|
||||
# Check blacklist
|
||||
target_character =
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
|
||||
[{pid, _}] ->
|
||||
case Character.get_state(pid) do
|
||||
{:ok, state} -> state
|
||||
_ -> nil
|
||||
end
|
||||
[] -> nil
|
||||
end
|
||||
|
||||
if target_character do
|
||||
not Enum.member?(target_character.blacklist, String.downcase(character.name))
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp create_buddy_entry(character, group, channel, pending) do
|
||||
%{
|
||||
character_id: character.id,
|
||||
name: character.name,
|
||||
group: group,
|
||||
channel: channel,
|
||||
visible: !pending,
|
||||
pending: pending,
|
||||
level: character.level,
|
||||
job: character.job
|
||||
}
|
||||
end
|
||||
|
||||
defp create_buddy_entry_from_db(character, group, pending) do
|
||||
%{
|
||||
character_id: character.id,
|
||||
name: character.name,
|
||||
group: group,
|
||||
channel: -1,
|
||||
visible: !pending,
|
||||
pending: pending,
|
||||
level: character.level,
|
||||
job: character.job
|
||||
}
|
||||
end
|
||||
|
||||
defp send_buddy_request(target_character, from_character) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target_character.id) do
|
||||
[{pid, _}] ->
|
||||
request_packet = Packets.request_buddylist_add(
|
||||
from_character.id,
|
||||
from_character.name,
|
||||
from_character.level,
|
||||
from_character.job
|
||||
)
|
||||
send(pid, {:send_packet, request_packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_buddy_added(target_id, from_character, group) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
|
||||
[{pid, _}] ->
|
||||
# Add buddy entry for target
|
||||
buddy_entry = create_buddy_entry(from_character, group, 1, false)
|
||||
|
||||
# Update target's buddies
|
||||
case Character.get_state(pid) do
|
||||
{:ok, target_state} ->
|
||||
updated_buddies = target_state.buddies ++ [buddy_entry]
|
||||
Character.update_buddies(target_id, updated_buddies)
|
||||
|
||||
# Send update packet
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 10)
|
||||
send(pid, {:send_packet, buddy_list_packet})
|
||||
_ -> :ok
|
||||
end
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_buddy_removed(target_id, remover_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
|
||||
[{pid, _}] ->
|
||||
case Character.get_state(pid) do
|
||||
{:ok, target_state} ->
|
||||
updated_buddies = Enum.reject(target_state.buddies, fn b ->
|
||||
b.character_id == remover_id
|
||||
end)
|
||||
|
||||
Character.update_buddies(target_id, updated_buddies)
|
||||
|
||||
buddy_list_packet = Packets.update_buddylist(updated_buddies, 18)
|
||||
send(pid, {:send_packet, buddy_list_packet})
|
||||
_ -> :ok
|
||||
end
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp find_buddy_channel(character_id) do
|
||||
# Try to find character on any channel
|
||||
# For now, just check current channel's registry
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{_pid, _}] -> 1 # Found, return channel
|
||||
[] -> -1 # Not found
|
||||
end
|
||||
end
|
||||
|
||||
defp send_buddy_message(client_state, code) do
|
||||
packet = Packets.buddylist_message(code)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp send_packet(client_state, packet) do
|
||||
if client_state.socket do
|
||||
:gen_tcp.send(client_state.socket, packet)
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Database Functions (Stubs)
|
||||
# ============================================================================
|
||||
|
||||
defp get_buddy_count_from_db(character_id) do
|
||||
# TODO: Query buddies table for count
|
||||
{:ok, 0}
|
||||
end
|
||||
|
||||
defp insert_pending_buddy(target_id, character_id, group_name) do
|
||||
# TODO: Insert pending buddy into database
|
||||
Logger.debug("Insert pending buddy: target=#{target_id}, from=#{character_id}, group=#{group_name}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp accept_buddy_in_db(character_id, other_id) do
|
||||
# TODO: Update buddy status in database
|
||||
Logger.debug("Accept buddy in DB: #{character_id} <-> #{other_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp remove_buddy_from_db(character_id, other_id) do
|
||||
# TODO: Remove buddy from database
|
||||
Logger.debug("Remove buddy from DB: #{character_id} X #{other_id}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ defmodule Odinsea.Channel.Handler.Chat do
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Admin.Handler, as: AdminHandler
|
||||
|
||||
@max_chat_length 80
|
||||
@max_staff_chat_length 512
|
||||
@@ -36,21 +37,25 @@ defmodule Odinsea.Channel.Handler.Chat do
|
||||
{:ok, client_state}
|
||||
|
||||
true ->
|
||||
# TODO: Process commands (CommandProcessor.processCommand)
|
||||
# TODO: Check if muted
|
||||
# TODO: Anti-spam checks
|
||||
# Check if this is an admin command
|
||||
if AdminHandler.admin_command?(message) do
|
||||
handle_admin_command(message, client_state)
|
||||
else
|
||||
# TODO: Check if muted
|
||||
# TODO: Anti-spam checks
|
||||
|
||||
# Broadcast chat to map
|
||||
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
|
||||
# Broadcast chat to map
|
||||
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
|
||||
|
||||
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
|
||||
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
|
||||
|
||||
# Log chat
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
|
||||
)
|
||||
# Log chat
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
@@ -263,4 +268,28 @@ defmodule Odinsea.Channel.Handler.Chat do
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Admin Command Handling
|
||||
# ============================================================================
|
||||
|
||||
defp handle_admin_command(message, client_state) do
|
||||
command_name = AdminHandler.extract_command_name(message)
|
||||
|
||||
Logger.info("Admin command detected: #{command_name} from character #{client_state.character_id}")
|
||||
|
||||
case AdminHandler.handle_command(message, client_state) do
|
||||
{:ok, result} ->
|
||||
Logger.info("Admin command succeeded: #{command_name} - #{result}")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Admin command failed: #{command_name} - #{reason}")
|
||||
{:ok, client_state}
|
||||
|
||||
:not_command ->
|
||||
# Shouldn't happen since we checked, but handle gracefully
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
224
lib/odinsea/channel/handler/duey.ex
Normal file
224
lib/odinsea/channel/handler/duey.ex
Normal file
@@ -0,0 +1,224 @@
|
||||
defmodule Odinsea.Channel.Handler.Duey do
|
||||
@moduledoc """
|
||||
Handles Duey (parcel delivery) system operations.
|
||||
|
||||
Ported from: src/handling/channel/handler/DueyHandler.java
|
||||
|
||||
Duey allows players to:
|
||||
- Send items and mesos to other players
|
||||
- Receive packages from other players
|
||||
- Remove/delete packages
|
||||
|
||||
## Status Codes
|
||||
- 19 = Successful
|
||||
- 18 = One-of-a-kind item already in receiver's delivery
|
||||
- 17 = Character unable to receive parcel
|
||||
- 15 = Same account
|
||||
- 14 = Name does not exist
|
||||
- 16 = Not enough space
|
||||
- 12 = Not enough mesos
|
||||
|
||||
## Main Handlers
|
||||
- handle_duey_operation/2 - All Duey operations (send, receive, remove)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Duey Operations
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles all Duey operations (CP_DUEY_ACTION / 0x48).
|
||||
|
||||
Operations:
|
||||
- 1: Start Duey (load packages)
|
||||
- 3: Send item/mesos
|
||||
- 5: Receive package
|
||||
- 6: Remove package
|
||||
- 8: Close Duey
|
||||
|
||||
Note: The original Java handler is mostly commented out.
|
||||
This is a stub implementation for future development.
|
||||
|
||||
Reference: DueyHandler.DueyOperation()
|
||||
"""
|
||||
def handle_duey_operation(packet, client_pid) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Check conversation state (should be 2 for Duey)
|
||||
# For now, allow without strict check since this is a stub
|
||||
|
||||
operation = In.decode_byte(packet)
|
||||
handle_duey_op(operation, packet, client_pid, character_id, char_state)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle Duey operation: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Individual Operations
|
||||
# ============================================================================
|
||||
|
||||
# Operation 1: Start Duey - Load packages
|
||||
defp handle_duey_op(1, packet, client_pid, character_id, _char_state) do
|
||||
# AS13Digit = packet.decodeString() # 13 digit AS code (unused)
|
||||
|
||||
# TODO: Load packages from database
|
||||
# packages = load_items(character_id)
|
||||
|
||||
# TODO: Send package list to client
|
||||
# packet = Packets.send_duey(10, packages)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
|
||||
Logger.debug("Duey start: character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 3: Send item/mesos
|
||||
defp handle_duey_op(3, packet, client_pid, character_id, char_state) do
|
||||
inventory_id = In.decode_byte(packet)
|
||||
item_pos = In.decode_short(packet)
|
||||
amount = In.decode_short(packet)
|
||||
mesos = In.decode_int(packet)
|
||||
recipient = In.decode_string(packet)
|
||||
quick_delivery = In.decode_byte(packet) > 0
|
||||
|
||||
# Calculate cost
|
||||
# tax = GameConstants.getTaxAmount(mesos)
|
||||
# final_cost = mesos + tax + (if quick_delivery, do: 0, else: 5000)
|
||||
|
||||
# TODO: Validate recipient exists
|
||||
# TODO: Validate recipient is not same account
|
||||
# TODO: Validate sender has enough mesos
|
||||
# TODO: Validate item exists if sending item
|
||||
# TODO: Check receiver has space
|
||||
# TODO: Add to database
|
||||
# TODO: Send success/failure packet
|
||||
|
||||
Logger.debug("Duey send: #{mesos} mesos (quick=#{quick_delivery}) to #{recipient}, item inv=#{inventory_id}, pos=#{item_pos}, amount=#{amount}, character #{character_id}")
|
||||
|
||||
# Send failure for now (not implemented)
|
||||
send(client_pid, {:send_packet, duey_error(17)})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 5: Receive package
|
||||
defp handle_duey_op(5, packet, client_pid, character_id, _char_state) do
|
||||
package_id = In.decode_int(packet)
|
||||
|
||||
# TODO: Load package from database
|
||||
# package = load_single_item(package_id, character_id)
|
||||
|
||||
# TODO: Validate package exists
|
||||
# TODO: Check inventory space
|
||||
# TODO: Add item/mesos to character
|
||||
# TODO: Remove from database
|
||||
# TODO: Send remove packet
|
||||
|
||||
Logger.debug("Duey receive: package #{package_id}, character #{character_id}")
|
||||
|
||||
# Send failure for now
|
||||
send(client_pid, {:send_packet, duey_error(17)})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 6: Remove package
|
||||
defp handle_duey_op(6, packet, client_pid, character_id, _char_state) do
|
||||
package_id = In.decode_int(packet)
|
||||
|
||||
# TODO: Remove from database
|
||||
# remove_item_from_db(package_id, character_id)
|
||||
|
||||
# TODO: Send remove confirmation
|
||||
# packet = Packets.remove_item_from_duey(true, package_id)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
|
||||
Logger.debug("Duey remove: package #{package_id}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Operation 8: Close Duey
|
||||
defp handle_duey_op(8, _packet, client_pid, character_id, _char_state) do
|
||||
# TODO: Set conversation state to 0
|
||||
Logger.debug("Duey close: character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Unknown operation
|
||||
defp handle_duey_op(operation, _packet, _client_pid, character_id, _char_state) do
|
||||
Logger.warning("Unknown Duey operation #{operation} from character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Database Operations (Stubs)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Loads all packages for a character.
|
||||
"""
|
||||
def load_items(character_id) do
|
||||
# TODO: Query dueypackages table
|
||||
# SELECT * FROM dueypackages WHERE RecieverId = ?
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a single package by ID.
|
||||
"""
|
||||
def load_single_item(package_id, character_id) do
|
||||
# TODO: Query dueypackages table
|
||||
# SELECT * FROM dueypackages WHERE PackageId = ? and RecieverId = ?
|
||||
nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds mesos to database.
|
||||
"""
|
||||
def add_meso_to_db(mesos, sender_name, recipient_id, is_online) do
|
||||
# TODO: INSERT INTO dueypackages (RecieverId, SenderName, Mesos, TimeStamp, Checked, Type)
|
||||
# VALUES (?, ?, ?, ?, ?, 3)
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds item to database.
|
||||
"""
|
||||
def add_item_to_db(item, quantity, mesos, sender_name, recipient_id, is_online) do
|
||||
# TODO: INSERT INTO dueypackages with item data
|
||||
# Use ItemLoader.DUEY.saveItems for item serialization
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes item from database.
|
||||
"""
|
||||
def remove_item_from_db(package_id, character_id) do
|
||||
# TODO: DELETE FROM dueypackages WHERE PackageId = ? and RecieverId = ?
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks messages as received (updates Checked flag).
|
||||
"""
|
||||
def receive_msg(character_id) do
|
||||
# TODO: UPDATE dueypackages SET Checked = 0 WHERE RecieverId = ?
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp duey_error(code) do
|
||||
# TODO: Implement proper Duey error packet
|
||||
# Packets.send_duey(code, nil)
|
||||
<<>>
|
||||
end
|
||||
end
|
||||
566
lib/odinsea/channel/handler/guild.ex
Normal file
566
lib/odinsea/channel/handler/guild.ex
Normal file
@@ -0,0 +1,566 @@
|
||||
defmodule Odinsea.Channel.Handler.Guild do
|
||||
@moduledoc """
|
||||
Handles guild operations.
|
||||
Ported from src/handling/channel/handler/GuildHandler.java
|
||||
|
||||
Manages guild create, join, leave, ranks, skills, and alliance.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.World.Guild
|
||||
|
||||
# Guild creation location (Henesys Guild Headquarters)
|
||||
@guild_creation_map_id 200_000_301
|
||||
@guild_create_cost 500_000
|
||||
@emblem_change_cost 1_500_000
|
||||
|
||||
# Invited list: {name => {guild_id, expiration_time}}
|
||||
@invited_table :guild_invited
|
||||
|
||||
@doc """
|
||||
Initializes the guild handler ETS table.
|
||||
"""
|
||||
def init do
|
||||
:ets.new(@invited_table, [:set, :public, :named_table])
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles guild operations (CP_GUILD_OPERATION).
|
||||
Ported from GuildHandler.Guild()
|
||||
|
||||
Operation:
|
||||
- 0x02: Create guild
|
||||
- 0x05: Invite player
|
||||
- 0x06: Accept invitation
|
||||
- 0x07: Leave guild
|
||||
- 0x08: Expel member
|
||||
- 0x0E: Change rank titles
|
||||
- 0x0F: Change member rank
|
||||
- 0x10: Change emblem
|
||||
- 0x11: Change notice
|
||||
- 0x1D: Purchase skill
|
||||
- 0x1E: Activate skill
|
||||
- 0x1F: Change leader
|
||||
"""
|
||||
def handle_guild_operation(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
|
||||
# Prune expired invites periodically
|
||||
prune_expired_invites()
|
||||
|
||||
{operation, packet} = In.decode_byte(packet)
|
||||
|
||||
Logger.debug("Guild operation: #{operation} from #{character.name}")
|
||||
|
||||
case operation do
|
||||
0x02 -> handle_create_guild(packet, character, client_state)
|
||||
0x05 -> handle_invite_player(packet, character, client_state)
|
||||
0x06 -> handle_accept_invitation(packet, character, client_state)
|
||||
0x07 -> handle_leave_guild(character, client_state)
|
||||
0x08 -> handle_expel_member(packet, character, client_state)
|
||||
0x0E -> handle_change_rank_titles(packet, character, client_state)
|
||||
0x0F -> handle_change_rank(packet, character, client_state)
|
||||
0x10 -> handle_change_emblem(packet, character, client_state)
|
||||
0x11 -> handle_change_notice(packet, character, client_state)
|
||||
0x1D -> handle_purchase_skill(packet, character, client_state)
|
||||
0x1E -> handle_activate_skill(packet, character, client_state)
|
||||
0x1F -> handle_change_leader(packet, character, client_state)
|
||||
_ ->
|
||||
Logger.warning("Unknown guild operation: #{operation}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Guild operation failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles guild request denial (CP_DENY_GUILD_REQUEST).
|
||||
Ported from GuildHandler.DenyGuildRequest()
|
||||
"""
|
||||
def handle_deny_guild_request(packet, client_state) do
|
||||
with {:ok, _character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(client_state.character_id) do
|
||||
|
||||
{from_name, _packet} = In.decode_string(packet)
|
||||
from_name = String.downcase(from_name)
|
||||
|
||||
# Remove from invited list
|
||||
case :ets.lookup(@invited_table, from_name) do
|
||||
[{^from_name, {guild_id, _expires}}] ->
|
||||
:ets.delete(@invited_table, from_name)
|
||||
|
||||
# Notify inviter
|
||||
notify_guild_denied(from_name, character.name)
|
||||
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Guild Operation Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_create_guild(packet, character, client_state) do
|
||||
cond do
|
||||
character.guild_id && character.guild_id > 0 ->
|
||||
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
|
||||
|
||||
character.map_id != @guild_creation_map_id ->
|
||||
Character.send_message(character.id, "You cannot create a new Guild while in one.", 1)
|
||||
|
||||
character.meso < @guild_create_cost ->
|
||||
Character.send_message(character.id, "You do not have enough mesos to create a Guild.", 1)
|
||||
|
||||
true ->
|
||||
{guild_name, _packet} = In.decode_string(packet)
|
||||
|
||||
if valid_guild_name?(guild_name) do
|
||||
case Guild.create_guild(character.id, guild_name) do
|
||||
{:ok, guild_id} ->
|
||||
# Deduct mesos
|
||||
Character.gain_meso(character.id, -@guild_create_cost, true, true)
|
||||
|
||||
# Set guild info
|
||||
Character.set_guild(character.id, guild_id, 1)
|
||||
Character.save_guild_status(character.id)
|
||||
|
||||
# TODO: Finish achievement 35
|
||||
|
||||
# Set online in guild
|
||||
Guild.set_online(guild_id, character.id, true, client_state.channel_id)
|
||||
|
||||
# Send guild info
|
||||
# TODO: Implement showGuildInfo packet
|
||||
|
||||
# Gain GP for creation
|
||||
Guild.gain_gp(guild_id, 500, character.id)
|
||||
|
||||
# Respawn player (update guild tag)
|
||||
respawn_player(character.id)
|
||||
|
||||
Character.send_message(character.id, "You have successfully created a Guild.", 1)
|
||||
|
||||
Logger.info("Guild '#{guild_name}' (ID: #{guild_id}) created by #{character.name}")
|
||||
|
||||
{:error, reason} ->
|
||||
Character.send_message(character.id, "Please try again.", 1)
|
||||
Logger.error("Failed to create guild: #{inspect(reason)}")
|
||||
end
|
||||
else
|
||||
Character.send_message(character.id, "The Guild name you have chosen is not accepted.", 1)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_invite_player(packet, character, client_state) do
|
||||
# Check if in guild and has invite permission (rank <= 2)
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
|
||||
{target_name, _packet} = In.decode_string(packet)
|
||||
target_name_lower = String.downcase(target_name)
|
||||
|
||||
# Check if already handling invitation
|
||||
case :ets.lookup(@invited_table, target_name_lower) do
|
||||
[{_, _}] ->
|
||||
Character.send_message(character.id, "The player is currently handling an invitation.", 5)
|
||||
|
||||
[] ->
|
||||
# Try to find target
|
||||
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name_lower) do
|
||||
{:ok, target} ->
|
||||
# Check if can invite
|
||||
if target.guild_id == nil || target.guild_id == 0 do
|
||||
# Send invite
|
||||
send_guild_invite(target, character)
|
||||
|
||||
# Add to invited list (expires in 20 minutes)
|
||||
expiration = System.system_time(:millisecond) + 20 * 60 * 1000
|
||||
:ets.insert(@invited_table, {target_name_lower, {character.guild_id, expiration}})
|
||||
else
|
||||
# TODO: Send appropriate error packet
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
# TODO: Send error packet
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_accept_invitation(packet, character, client_state) do
|
||||
if character.guild_id && character.guild_id > 0 do
|
||||
# Already in guild
|
||||
{:ok, client_state}
|
||||
else
|
||||
{guild_id, packet} = In.decode_int(packet)
|
||||
{cid, _packet} = In.decode_int(packet)
|
||||
|
||||
# Verify character ID matches
|
||||
if cid == character.id do
|
||||
target_name = String.downcase(character.name)
|
||||
|
||||
case :ets.lookup(@invited_table, target_name) do
|
||||
[{^target_name, {^guild_id, _expires}}] ->
|
||||
# Remove from invited
|
||||
:ets.delete(@invited_table, target_name)
|
||||
|
||||
# Join guild
|
||||
case Guild.add_member(guild_id, character) do
|
||||
{:ok, _member} ->
|
||||
# Set guild info
|
||||
Character.set_guild(character.id, guild_id, 5)
|
||||
Character.save_guild_status(character.id)
|
||||
|
||||
# Send guild info
|
||||
# TODO: Implement showGuildInfo packet
|
||||
|
||||
# Send alliance info if applicable
|
||||
guild = Guild.get_guild(guild_id)
|
||||
if guild && guild.alliance_id > 0 do
|
||||
# TODO: Send alliance info
|
||||
:ok
|
||||
end
|
||||
|
||||
# Respawn player
|
||||
respawn_player(character.id)
|
||||
|
||||
{:error, :guild_full} ->
|
||||
Character.send_message(character.id, "The Guild you are trying to join is already full.", 1)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to add guild member: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
[] ->
|
||||
# No pending invitation
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_leave_guild(character, client_state) do
|
||||
if character.guild_id && character.guild_id > 0 do
|
||||
case Guild.leave_guild(character.guild_id, character.id) do
|
||||
:ok ->
|
||||
# Clear guild info
|
||||
Character.set_guild(character.id, 0, 5)
|
||||
Character.save_guild_status(character.id)
|
||||
|
||||
# Send empty guild info
|
||||
# TODO: Implement showGuildInfo with null
|
||||
|
||||
Logger.info("#{character.name} left guild #{character.guild_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to leave guild: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_expel_member(packet, character, client_state) do
|
||||
{target_id, packet} = In.decode_int(packet)
|
||||
{target_name, _packet} = In.decode_string(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
|
||||
case Guild.expel_member(character.guild_id, character.id, target_id, target_name) do
|
||||
:ok ->
|
||||
# Update expelled character if online
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
|
||||
[{pid, _}] ->
|
||||
Character.set_guild(target_id, 0, 5)
|
||||
# TODO: Send guild info update
|
||||
[] ->
|
||||
# Send note to offline character
|
||||
send_note(target_name, character.name, "You have been expelled from the guild.")
|
||||
end
|
||||
|
||||
Logger.info("#{target_name} expelled from guild by #{character.name}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to expel member: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_change_rank_titles(packet, character, client_state) do
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
|
||||
# Read 5 rank titles
|
||||
titles = for _i <- 1..5 do
|
||||
{title, remaining} = In.decode_string(packet)
|
||||
packet = remaining
|
||||
title
|
||||
end
|
||||
|
||||
case Guild.change_rank_titles(character.guild_id, titles, character.id) do
|
||||
:ok ->
|
||||
Logger.info("Guild #{character.guild_id} rank titles changed")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to change rank titles: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_change_rank(packet, character, client_state) do
|
||||
{target_id, packet} = In.decode_byte(packet)
|
||||
{new_rank, _packet} = In.decode_byte(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
|
||||
# Validate rank
|
||||
if new_rank > 1 && new_rank <= 5 && (new_rank > 2 || character.guild_rank == 1) do
|
||||
case Guild.change_rank(character.guild_id, target_id, new_rank, character.id) do
|
||||
:ok ->
|
||||
# Update target's rank if online
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target_id) do
|
||||
[{pid, _}] ->
|
||||
Character.set_guild_rank(target_id, new_rank)
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to change rank: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_change_emblem(packet, character, client_state) do
|
||||
cond do
|
||||
character.guild_id == nil || character.guild_id == 0 ->
|
||||
{:ok, client_state}
|
||||
|
||||
character.guild_rank != 1 ->
|
||||
{:ok, client_state}
|
||||
|
||||
character.map_id != @guild_creation_map_id ->
|
||||
{:ok, client_state}
|
||||
|
||||
character.meso < @emblem_change_cost ->
|
||||
Character.send_message(character.id, "You do not have enough mesos to create an emblem.", 1)
|
||||
{:ok, client_state}
|
||||
|
||||
true ->
|
||||
{bg, packet} = In.decode_short(packet)
|
||||
{bg_color, packet} = In.decode_byte(packet)
|
||||
{logo, packet} = In.decode_short(packet)
|
||||
{logo_color, _packet} = In.decode_byte(packet)
|
||||
|
||||
case Guild.set_emblem(character.guild_id, bg, bg_color, logo, logo_color, character.id) do
|
||||
:ok ->
|
||||
# Deduct mesos
|
||||
Character.gain_meso(character.id, -@emblem_change_cost, true, true)
|
||||
|
||||
# Respawn all members to update emblem
|
||||
respawn_all_guild_members(character.guild_id)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to change emblem: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_change_notice(packet, character, client_state) do
|
||||
{notice, _packet} = In.decode_string(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank <= 2 do
|
||||
if String.length(notice) <= 100 do
|
||||
case Guild.set_notice(character.guild_id, notice, character.id) do
|
||||
:ok ->
|
||||
Logger.info("Guild #{character.guild_id} notice changed")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to change notice: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_purchase_skill(packet, character, client_state) do
|
||||
{skill_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 do
|
||||
# TODO: Validate skill and level
|
||||
# TODO: Check if character has enough mesos
|
||||
|
||||
case Guild.purchase_skill(character.guild_id, skill_id, character.name, character.id) do
|
||||
{:ok, _level} ->
|
||||
# Deduct mesos
|
||||
# TODO: Get skill price
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to purchase guild skill: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_activate_skill(packet, character, client_state) do
|
||||
{skill_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 do
|
||||
# TODO: Check if skill is purchased and not expired
|
||||
# TODO: Check if character has enough mesos for extension
|
||||
|
||||
case Guild.activate_skill(character.guild_id, skill_id, character.name) do
|
||||
:ok ->
|
||||
# Deduct mesos
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to activate guild skill: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_change_leader(packet, character, client_state) do
|
||||
{new_leader_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.guild_id && character.guild_id > 0 && character.guild_rank == 1 do
|
||||
# Get current leader
|
||||
guild = Guild.get_guild(character.guild_id)
|
||||
|
||||
if guild && guild.leader_id != new_leader_id do
|
||||
case Guild.change_leader(character.guild_id, new_leader_id, character.id) do
|
||||
:ok ->
|
||||
# Update ranks
|
||||
Character.set_guild_rank(character.id, 2)
|
||||
Character.set_guild_rank(new_leader_id, 1)
|
||||
|
||||
{:error, reason} ->
|
||||
Character.send_message(character.id, "This user is already the guild leader.", 1)
|
||||
Logger.error("Failed to change leader: #{inspect(reason)}")
|
||||
end
|
||||
else
|
||||
Character.send_message(character.id, "This user is already the guild leader.", 1)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil -> {:error, :no_character}
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_guild_name?(name) do
|
||||
cond do
|
||||
String.length(name) < 3 -> false
|
||||
String.length(name) > 12 -> false
|
||||
true -> Regex.match?(~r/^[a-zA-Z]+$/, name)
|
||||
end
|
||||
end
|
||||
|
||||
defp send_guild_invite(target, inviter) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
|
||||
[{pid, _}] ->
|
||||
invite_packet = Packets.guild_invite(inviter)
|
||||
send(pid, {:send_packet, invite_packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_guild_denied(inviter_name, denier_name) do
|
||||
# Find inviter and send denial
|
||||
case Odinsea.Channel.Players.find_by_name(1, inviter_name) do
|
||||
{:ok, inviter} ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, inviter.id) do
|
||||
[{pid, _}] ->
|
||||
packet = Packets.deny_guild_invitation(denier_name)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
{:error, _} -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp send_note(to_name, from_name, message) do
|
||||
# TODO: Implement note sending via database
|
||||
Logger.debug("Note to #{to_name} from #{from_name}: #{message}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp respawn_player(character_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] ->
|
||||
case Character.get_state(pid) do
|
||||
{:ok, character} ->
|
||||
# Broadcast guild name and icon update
|
||||
# TODO: Implement proper respawn
|
||||
:ok
|
||||
_ -> :ok
|
||||
end
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp respawn_all_guild_members(guild_id) do
|
||||
case Guild.get_guild(guild_id) do
|
||||
nil -> :ok
|
||||
guild ->
|
||||
Enum.each(guild.members, fn member ->
|
||||
if member.online do
|
||||
respawn_player(member.id)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp prune_expired_invites do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
:ets.select_delete(@invited_table, [
|
||||
{{:_, {:_, :"$1"}}, [{:<, :"$1", now}], [true]}
|
||||
])
|
||||
end
|
||||
end
|
||||
641
lib/odinsea/channel/handler/item_maker.ex
Normal file
641
lib/odinsea/channel/handler/item_maker.ex
Normal file
@@ -0,0 +1,641 @@
|
||||
defmodule Odinsea.Channel.Handler.ItemMaker do
|
||||
@moduledoc """
|
||||
Handles item crafting/making operations.
|
||||
|
||||
Ported from: src/handling/channel/handler/ItemMakerHandler.java
|
||||
|
||||
## Maker Types
|
||||
- 1: Create items/gems/equipment
|
||||
- 3: Make crystals from etc items
|
||||
- 4: Disassemble equipment
|
||||
|
||||
## Profession Skills
|
||||
- 92000000: Herbalism
|
||||
- 92010000: Mining
|
||||
- 92020000: Smithing
|
||||
- 92030000: Accessory Crafting
|
||||
- 92040000: Alchemy
|
||||
|
||||
## Main Handlers
|
||||
- handle_item_maker/2 - Item crafting
|
||||
- handle_use_recipe/2 - Recipe usage
|
||||
- handle_make_extractor/2 - Extractor creation
|
||||
- handle_use_bag/2 - Herb/Mining bag usage
|
||||
- handle_start_harvest/2 - Start harvesting
|
||||
- handle_stop_harvest/2 - Stop harvesting
|
||||
- handle_profession_info/2 - Profession info request
|
||||
- handle_craft_effect/2 - Crafting animation effect
|
||||
- handle_craft_make/2 - Crafting make animation
|
||||
- handle_craft_complete/2 - Crafting completion
|
||||
- handle_use_pot/2 - Item pot usage
|
||||
- handle_clear_pot/2 - Clear item pot
|
||||
- handle_feed_pot/2 - Feed item pot
|
||||
- handle_cure_pot/2 - Cure item pot
|
||||
- handle_reward_pot/2 - Reward from item pot
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.{Character, Inventory}
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# Crafting effect mapping
|
||||
@crafting_effects %{
|
||||
"Effect/BasicEff.img/professions/herbalism" => 92000000,
|
||||
"Effect/BasicEff.img/professions/mining" => 92010000,
|
||||
"Effect/BasicEff.img/professions/herbalismExtract" => 92000000,
|
||||
"Effect/BasicEff.img/professions/miningExtract" => 92010000,
|
||||
"Effect/BasicEff.img/professions/equip_product" => 92020000,
|
||||
"Effect/BasicEff.img/professions/acc_product" => 92030000,
|
||||
"Effect/BasicEff.img/professions/alchemy" => 92040000
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Item Making
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles item maker operations (CP_ITEM_MAKER / 0x87).
|
||||
|
||||
Maker types:
|
||||
- 1: Gem creation, other gem creation, or equipment making
|
||||
- 3: Crystal making from etc items
|
||||
- 4: Equipment disassembly
|
||||
|
||||
Reference: ItemMakerHandler.ItemMaker()
|
||||
"""
|
||||
def handle_item_maker(packet, client_pid) do
|
||||
maker_type = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
case maker_type do
|
||||
1 -> handle_make_item(packet, client_pid, character_id, char_state)
|
||||
3 -> handle_make_crystal(packet, client_pid, character_id, char_state)
|
||||
4 -> handle_disassemble(packet, client_pid, character_id, char_state)
|
||||
_ ->
|
||||
Logger.warning("Unknown maker type: #{maker_type}, character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle item maker: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle type 1: Make item/gem/equipment
|
||||
defp handle_make_item(packet, client_pid, character_id, char_state) do
|
||||
to_create = In.decode_int(packet)
|
||||
|
||||
# Check what type of creation this is
|
||||
cond do
|
||||
is_gem?(to_create) ->
|
||||
# Gem creation with random reward
|
||||
handle_gem_creation(packet, client_pid, character_id, char_state, to_create)
|
||||
|
||||
is_other_gem?(to_create) ->
|
||||
# Non-gem items created with gem recipe
|
||||
handle_other_gem_creation(packet, client_pid, character_id, char_state, to_create)
|
||||
|
||||
true ->
|
||||
# Equipment creation
|
||||
handle_equip_creation(packet, client_pid, character_id, char_state, to_create)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_gem_creation(packet, client_pid, character_id, char_state, item_id) do
|
||||
# TODO: Get gem info from ItemMakerFactory
|
||||
# gem = ItemMakerFactory.get_gem_info(item_id)
|
||||
|
||||
# TODO: Check skill level
|
||||
# TODO: Check meso cost
|
||||
# TODO: Check inventory space
|
||||
# TODO: Remove required items
|
||||
# TODO: Give random gem reward
|
||||
|
||||
Logger.debug("Gem creation: item #{item_id}, character #{character_id}")
|
||||
|
||||
# Send success packet
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_other_gem_creation(packet, client_pid, character_id, char_state, item_id) do
|
||||
# TODO: Similar to gem creation but with fixed reward
|
||||
|
||||
Logger.debug("Other gem creation: item #{item_id}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_equip_creation(packet, client_pid, character_id, char_state, item_id) do
|
||||
stimulator = In.decode_byte(packet) > 0
|
||||
num_enchanter = In.decode_int(packet)
|
||||
|
||||
# TODO: Get creation info from ItemMakerFactory
|
||||
# create = ItemMakerFactory.get_create_info(item_id)
|
||||
|
||||
# TODO: Validate enchanter count <= TUC
|
||||
# TODO: Check skill level
|
||||
# TODO: Check meso cost
|
||||
# TODO: Check inventory space
|
||||
# TODO: Remove required items
|
||||
# TODO: Create equipment with optional stimulator/enchanters
|
||||
|
||||
Logger.debug("Equip creation: item #{item_id}, stimulator=#{stimulator}, enchanters=#{num_enchanter}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Handle type 3: Make crystals
|
||||
defp handle_make_crystal(packet, client_pid, character_id, char_state) do
|
||||
etc_id = In.decode_int(packet)
|
||||
|
||||
# TODO: Validate player has 100 of the etc item
|
||||
# TODO: Get crystal ID based on item level
|
||||
# crystal_id = get_create_crystal(etc_id)
|
||||
# TODO: Add crystal to inventory
|
||||
# TODO: Remove etc items
|
||||
|
||||
Logger.debug("Crystal creation: etc #{etc_id}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Handle type 4: Disassemble equipment
|
||||
defp handle_disassemble(packet, client_pid, character_id, char_state) do
|
||||
item_id = In.decode_int(packet)
|
||||
tick = In.decode_int(packet)
|
||||
slot = In.decode_int(packet)
|
||||
|
||||
# TODO: Validate item exists in equip inventory
|
||||
# TODO: Get item level
|
||||
# TODO: Calculate crystal reward
|
||||
# TODO: Add crystals to inventory
|
||||
# TODO: Remove equipment
|
||||
|
||||
Logger.debug("Disassemble: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Recipe Usage
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles recipe usage (CP_USE_RECIPE / 0x5A).
|
||||
|
||||
Recipes are items that teach crafting skills.
|
||||
|
||||
Reference: ItemMakerHandler.UseRecipe()
|
||||
"""
|
||||
def handle_use_recipe(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate item is recipe (item_id / 10000 == 251)
|
||||
# TODO: Apply recipe effect (learn skill)
|
||||
# TODO: Remove recipe item
|
||||
|
||||
Logger.debug("Use recipe: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use recipe: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Extractor
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles extractor creation (CP_MAKE_EXTRACTOR / 0x114).
|
||||
|
||||
Extractors allow other players to use your profession skills.
|
||||
|
||||
Reference: ItemMakerHandler.MakeExtractor()
|
||||
"""
|
||||
def handle_make_extractor(packet, client_pid) do
|
||||
item_id = In.decode_int(packet)
|
||||
fee = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Handle removing extractor (negative item_id)
|
||||
if item_id < 0 do
|
||||
# TODO: Remove extractor
|
||||
Logger.debug("Remove extractor: character #{character_id}")
|
||||
else
|
||||
# TODO: Validate item is extractor (item_id / 10000 == 304)
|
||||
# TODO: Validate fee > 0
|
||||
# TODO: Validate in town
|
||||
# TODO: Create extractor on map
|
||||
|
||||
Logger.debug("Make extractor: item #{item_id}, fee #{fee}, character #{character_id}")
|
||||
end
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to make extractor: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Bags
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles bag usage (CP_USE_BAG / 0x68).
|
||||
|
||||
Herb bags and mining bags extend inventory.
|
||||
|
||||
Reference: ItemMakerHandler.UseBag()
|
||||
"""
|
||||
def handle_use_bag(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate item is bag (item_id / 10000 == 433)
|
||||
# TODO: Add to extended slots if first time
|
||||
# TODO: Open bag UI
|
||||
|
||||
Logger.debug("Use bag: item #{item_id} at slot #{slot}, tick #{tick}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use bag: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Harvesting
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles start harvest (CP_START_HARVEST / 0x12E).
|
||||
|
||||
Reference: ItemMakerHandler.StartHarvest()
|
||||
"""
|
||||
def handle_start_harvest(packet, client_pid) do
|
||||
reactor_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get reactor from map
|
||||
# TODO: Validate reactor is valid for harvesting
|
||||
# TODO: Check harvesting tool
|
||||
# TODO: Check fatigue
|
||||
# TODO: Check harvest cooldown
|
||||
# TODO: Send harvest OK message
|
||||
|
||||
Logger.debug("Start harvest: reactor #{reactor_oid}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to start harvest: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles stop harvest (CP_STOP_HARVEST / 0x12F).
|
||||
|
||||
Reference: ItemMakerHandler.StopHarvest()
|
||||
"""
|
||||
def handle_stop_harvest(packet, client_pid) do
|
||||
reactor_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Process harvest completion
|
||||
# TODO: Give items
|
||||
# TODO: Destroy reactor
|
||||
# TODO: Trigger reactor script
|
||||
|
||||
Logger.debug("Stop harvest: reactor #{reactor_oid}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to stop harvest: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Profession Info
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles profession info request (CP_PROFESSION_INFO / 0x97).
|
||||
|
||||
Reference: ItemMakerHandler.ProfessionInfo()
|
||||
"""
|
||||
def handle_profession_info(packet, client_pid) do
|
||||
profession_str = In.decode_string(packet)
|
||||
level1 = In.decode_int(packet)
|
||||
level2 = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Parse profession string to get skill ID
|
||||
profession_id = String.to_integer(profession_str)
|
||||
|
||||
# Calculate progress percentage
|
||||
# progress = max(0, 100 - ((level1 + 1) - profession_level) * 20)
|
||||
|
||||
# TODO: Send profession info packet
|
||||
# packet = Packets.profession_info(profession_str, level1, level2, progress)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
|
||||
Logger.debug("Profession info: #{profession_id}, levels #{level1}/#{level2}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to get profession info: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Crafting Animations
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles crafting effect (CP_CRAFT_EFFECT / 0xC9).
|
||||
|
||||
Shows crafting animation to player and others.
|
||||
|
||||
Reference: ItemMakerHandler.CraftEffect()
|
||||
"""
|
||||
def handle_craft_effect(packet, client_pid) do
|
||||
effect = In.decode_string(packet)
|
||||
time = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Validate map (Ardentmill or has extractor)
|
||||
valid_map = char_state.map == 910001000 #|| has_extractor_nearby?
|
||||
|
||||
if valid_map do
|
||||
profession = Map.get(@crafting_effects, effect)
|
||||
|
||||
if profession do
|
||||
# Clamp time to 3-6 seconds
|
||||
time = max(3000, min(6000, time))
|
||||
|
||||
is_extract = String.ends_with?(effect, "Extract")
|
||||
|
||||
# TODO: Broadcast crafting effect
|
||||
# packet = Packets.show_own_crafting_effect(effect, time, is_extract)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
|
||||
# TODO: Broadcast to others
|
||||
# packet = Packets.show_crafting_effect(character_id, effect, time, is_extract)
|
||||
# Map.broadcast_packet(char_state.map, packet, exclude: character_id)
|
||||
end
|
||||
end
|
||||
|
||||
Logger.debug("Craft effect: #{effect}, time #{time}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle craft effect: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles craft make animation (CP_CRAFT_MAKE / 0xCA).
|
||||
|
||||
Broadcasts crafting animation to map.
|
||||
|
||||
Reference: ItemMakerHandler.CraftMake()
|
||||
"""
|
||||
def handle_craft_make(packet, client_pid) do
|
||||
something = In.decode_int(packet)
|
||||
time = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Clamp time
|
||||
time = max(3000, min(6000, time))
|
||||
|
||||
# TODO: Broadcast craft make animation
|
||||
# packet = Packets.craft_make(character_id, something, time)
|
||||
# Map.broadcast_packet(char_state.map, packet)
|
||||
|
||||
Logger.debug("Craft make: #{something}, time #{time}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle craft make: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles craft completion (CP_CRAFT_DONE / 0xC8).
|
||||
|
||||
Processes crafting results.
|
||||
|
||||
Reference: ItemMakerHandler.CraftComplete()
|
||||
"""
|
||||
def handle_craft_complete(packet, client_pid) do
|
||||
craft_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get crafting entry from SkillFactory
|
||||
# ce = SkillFactory.get_craft(craft_id)
|
||||
|
||||
# TODO: Check profession level
|
||||
# TODO: Check fatigue
|
||||
# TODO: Process disassembly, fusing, or normal crafting
|
||||
# TODO: Calculate success/failure
|
||||
# TODO: Give items
|
||||
# TODO: Add profession EXP
|
||||
# TODO: Add fatigue
|
||||
|
||||
Logger.debug("Craft complete: #{craft_id}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to complete craft: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Item Pot (Imps)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles use item pot (CP_USE_POT / 0x98).
|
||||
|
||||
Summons an item pot (imp) pet.
|
||||
|
||||
Reference: ItemMakerHandler.UsePot()
|
||||
"""
|
||||
def handle_use_pot(packet, client_pid) do
|
||||
item_id = In.decode_int(packet)
|
||||
slot = In.decode_short(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate item is pot item (item_id / 10000 == 244)
|
||||
# TODO: Check for empty imp slot
|
||||
# TODO: Create imp
|
||||
# TODO: Remove item
|
||||
|
||||
Logger.debug("Use pot: item #{item_id} at slot #{slot}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use pot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles clear item pot (CP_CLEAR_POT / 0x99).
|
||||
|
||||
Removes an item pot.
|
||||
|
||||
Reference: ItemMakerHandler.ClearPot()
|
||||
"""
|
||||
def handle_clear_pot(packet, client_pid) do
|
||||
index = In.decode_int(packet) - 1
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate index
|
||||
# TODO: Remove imp
|
||||
|
||||
Logger.debug("Clear pot: index #{index}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to clear pot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles feed item pot (CP_FEED_POT / 0x9A).
|
||||
|
||||
Feeds item to imp to level it up.
|
||||
|
||||
Reference: ItemMakerHandler.FeedPot()
|
||||
"""
|
||||
def handle_feed_pot(packet, client_pid) do
|
||||
item_id = In.decode_int(packet)
|
||||
slot = In.decode_int(packet)
|
||||
index = In.decode_int(packet) - 1
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate imp exists
|
||||
# TODO: Validate item level range
|
||||
# TODO: Add fullness/closeness
|
||||
# TODO: Level up if full
|
||||
# TODO: Remove item
|
||||
|
||||
Logger.debug("Feed pot: item #{item_id} at #{slot}, index #{index}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to feed pot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles cure item pot (CP_CURE_POT / 0x9B).
|
||||
|
||||
Cures a sick imp.
|
||||
|
||||
Reference: ItemMakerHandler.CurePot()
|
||||
"""
|
||||
def handle_cure_pot(packet, client_pid) do
|
||||
item_id = In.decode_int(packet)
|
||||
slot = In.decode_int(packet)
|
||||
index = In.decode_int(packet) - 1
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate imp is sick
|
||||
# TODO: Validate cure item (item_id / 10000 == 434)
|
||||
# TODO: Cure imp
|
||||
# TODO: Remove item
|
||||
|
||||
Logger.debug("Cure pot: item #{item_id} at #{slot}, index #{index}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to cure pot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles reward from item pot (CP_REWARD_POT / 0x9C).
|
||||
|
||||
Claims reward from fully grown imp.
|
||||
|
||||
Reference: ItemMakerHandler.RewardPot()
|
||||
"""
|
||||
def handle_reward_pot(packet, client_pid) do
|
||||
index = In.decode_int(packet) - 1
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate imp is max level
|
||||
# TODO: Calculate reward based on imp type and closeness
|
||||
# TODO: Give reward item
|
||||
# TODO: Remove imp
|
||||
|
||||
Logger.debug("Reward pot: index #{index}, character #{character_id}")
|
||||
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to reward pot: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp is_gem?(item_id) do
|
||||
# Gems are in specific ID ranges
|
||||
# TODO: Implement proper check from GameConstants
|
||||
item_id >= 4000000 and item_id < 4001000
|
||||
end
|
||||
|
||||
defp is_other_gem?(item_id) do
|
||||
# Other items that use gem crafting
|
||||
# TODO: Implement proper check from GameConstants
|
||||
false
|
||||
end
|
||||
end
|
||||
356
lib/odinsea/channel/handler/mob.ex
Normal file
356
lib/odinsea/channel/handler/mob.ex
Normal file
@@ -0,0 +1,356 @@
|
||||
defmodule Odinsea.Channel.Handler.Mob do
|
||||
@moduledoc """
|
||||
Handles all mob (monster) related packets from the client.
|
||||
|
||||
Ported from: src/handling/channel/handler/MobHandler.java
|
||||
|
||||
## Main Handlers
|
||||
- handle_mob_move/2 - Monster movement from controller
|
||||
- handle_auto_aggro/2 - Monster aggro request
|
||||
- handle_mob_skill_delay_end/2 - Monster skill execution
|
||||
- handle_mob_bomb/2 - Monster self-destruct
|
||||
- handle_mob_hit_by_mob/2 - Mob to mob damage
|
||||
"""
|
||||
|
||||
require Logger
|
||||
use Bitwise
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.{Character, Map, Movement}
|
||||
alias Odinsea.Game.Movement.Path
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Packet Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles monster movement from the controlling client (CP_MOVE_LIFE / 0xF3).
|
||||
|
||||
Flow:
|
||||
1. Client sends mob movement when they control the mob
|
||||
2. Server validates the movement
|
||||
3. Server broadcasts movement to other players
|
||||
|
||||
Reference: MobHandler.onMobMove()
|
||||
"""
|
||||
def handle_mob_move(packet, client_pid) do
|
||||
# Decode packet
|
||||
mob_id = In.decode_int(packet)
|
||||
mob_ctrl_sn = In.decode_short(packet)
|
||||
mob_ctrl_state = In.decode_byte(packet)
|
||||
next_attack_possible = (mob_ctrl_state &&& 0x0F) != 0
|
||||
action = In.decode_byte(packet)
|
||||
data = In.decode_int(packet)
|
||||
|
||||
# Multi-target for ball
|
||||
multi_target_count = In.decode_int(packet)
|
||||
packet = Enum.reduce(1..multi_target_count, packet, fn _, acc_packet ->
|
||||
acc_packet
|
||||
|> In.decode_int() # x
|
||||
|> In.decode_int() # y
|
||||
end)
|
||||
|
||||
# Rand time for area attack
|
||||
rand_time_count = In.decode_int(packet)
|
||||
packet = Enum.reduce(1..rand_time_count, packet, fn _, acc_packet ->
|
||||
In.decode_int(acc_packet) # rand time
|
||||
end)
|
||||
|
||||
# Movement validation fields
|
||||
_is_cheat_mob_move_rand = In.decode_byte(packet)
|
||||
_hacked_code = In.decode_int(packet)
|
||||
_target_x = In.decode_int(packet)
|
||||
_target_y = In.decode_int(packet)
|
||||
_hacked_code_crc = In.decode_int(packet)
|
||||
|
||||
# Parse MovePath (newer mob movement system)
|
||||
move_path = Path.decode(packet, false)
|
||||
|
||||
# Parse additional passive data
|
||||
_b_chasing = In.decode_byte(packet)
|
||||
_has_target = In.decode_byte(packet)
|
||||
_target_b_chasing = In.decode_byte(packet)
|
||||
_target_b_chasing_hack = In.decode_byte(packet)
|
||||
_chase_duration = In.decode_int(packet)
|
||||
|
||||
# Get character state
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Update monster position if path has elements
|
||||
final_pos = Path.get_final_position(move_path)
|
||||
final_action = Path.get_final_action(move_path)
|
||||
final_foothold = Path.get_final_foothold(move_path)
|
||||
|
||||
# TODO: Validate monster controller
|
||||
# TODO: Update monster in map
|
||||
# TODO: Broadcast movement to other players
|
||||
|
||||
Logger.debug(
|
||||
"Mob move: OID #{mob_id}, action #{action}, " <>
|
||||
"pos (#{final_pos.x}, #{final_pos.y}), " <>
|
||||
"elements #{length(move_path.elements)}, character #{character_id}"
|
||||
)
|
||||
|
||||
# Send control ack back to client
|
||||
ack_packet = Packets.mob_ctrl_ack(mob_id, mob_ctrl_sn, next_attack_possible, 100, 0, 0)
|
||||
send(client_pid, {:send_packet, ack_packet})
|
||||
|
||||
# Broadcast movement to other players if path has elements
|
||||
if length(move_path.elements) > 0 do
|
||||
broadcast_mob_move(
|
||||
char_state.map,
|
||||
char_state.channel_id,
|
||||
mob_id,
|
||||
next_attack_possible,
|
||||
action,
|
||||
move_path,
|
||||
character_id
|
||||
)
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob move: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster auto-aggro (CP_AUTO_AGGRO / 0xFC).
|
||||
|
||||
When a monster detects a player, the client sends this packet to request control.
|
||||
|
||||
Reference: MobHandler.AutoAggro()
|
||||
"""
|
||||
def handle_auto_aggro(packet, client_pid) do
|
||||
monster_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
map_id = char_state.map
|
||||
channel_id = char_state.channel_id
|
||||
|
||||
# TODO: Implement controller assignment
|
||||
# TODO: Check distance between player and monster
|
||||
# TODO: Assign monster control to this player
|
||||
|
||||
Logger.debug("Auto aggro: Monster OID #{monster_oid}, character #{character_id}")
|
||||
|
||||
# For now, just acknowledge
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle auto aggro: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster skill delay end (CP_MOB_SKILL_DELAY_END / 0xFE).
|
||||
|
||||
After a monster skill animation delay, the client sends this to execute the skill effect.
|
||||
|
||||
Reference: MobHandler.onMobSkillDelayEnd()
|
||||
"""
|
||||
def handle_mob_skill_delay_end(packet, client_pid) do
|
||||
monster_oid = In.decode_int(packet)
|
||||
skill_id = In.decode_int(packet)
|
||||
skill_lv = In.decode_int(packet)
|
||||
# _option = In.decode_int(packet) # Sometimes present
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Validate monster has this skill
|
||||
# TODO: Execute mob skill effect (stun, poison, etc.)
|
||||
# TODO: Apply skill to players in range
|
||||
|
||||
Logger.debug("Mob skill delay end: OID #{monster_oid}, skill #{skill_id} lv #{skill_lv}, character #{character_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob skill delay end: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster bomb/self-destruct (CP_MOB_BOMB / 0xFF).
|
||||
|
||||
Some monsters explode when their timer runs out or when triggered.
|
||||
|
||||
Reference: MobHandler.MobBomb()
|
||||
"""
|
||||
def handle_mob_bomb(packet, client_pid) do
|
||||
monster_oid = In.decode_int(packet)
|
||||
_unknown = In.decode_short(packet) # 9E 07 or similar
|
||||
_damage = In.decode_int(packet) # -204 or similar
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
map_id = char_state.map
|
||||
channel_id = char_state.channel_id
|
||||
|
||||
# TODO: Check if monster has TimeBomb buff
|
||||
# TODO: Execute bomb explosion
|
||||
# TODO: Damage players in range
|
||||
# TODO: Kill monster
|
||||
|
||||
Logger.debug("Mob bomb: OID #{monster_oid}, character #{character_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob bomb: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles mob-to-mob damage (when monsters attack each other).
|
||||
|
||||
Used for friendly mobs like Shammos escort quests.
|
||||
|
||||
Reference: MobHandler.OnMobHitByMob(), MobHandler.OnMobAttackMob()
|
||||
"""
|
||||
def handle_mob_hit_by_mob(packet, client_pid) do
|
||||
mob_from_oid = In.decode_int(packet)
|
||||
_player_id = In.decode_int(packet)
|
||||
mob_to_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
map_id = char_state.map
|
||||
channel_id = char_state.channel_id
|
||||
|
||||
# TODO: Validate both monsters exist
|
||||
# TODO: Check if target monster is friendly
|
||||
# TODO: Calculate and apply damage
|
||||
# TODO: Check for special escort quest logic (Shammos)
|
||||
|
||||
Logger.debug("Mob hit by mob: From OID #{mob_from_oid}, to OID #{mob_to_oid}, character #{character_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob hit by mob: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles mob-to-mob attack damage packet (CP_MOB_ATTACK_MOB).
|
||||
|
||||
Similar to handle_mob_hit_by_mob but with more damage information.
|
||||
|
||||
Reference: MobHandler.OnMobAttackMob()
|
||||
"""
|
||||
def handle_mob_attack_mob(packet, client_pid) do
|
||||
mob_from_oid = In.decode_int(packet)
|
||||
_player_id = In.decode_int(packet)
|
||||
mob_to_oid = In.decode_int(packet)
|
||||
_skill_or_bump = In.decode_byte(packet) # -1 = bump, otherwise skill ID
|
||||
damage = In.decode_int(packet)
|
||||
|
||||
# Damage cap check
|
||||
if damage > 30_000 do
|
||||
Logger.warn("Suspicious mob-to-mob damage: #{damage}")
|
||||
:ok
|
||||
else
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
map_id = char_state.map
|
||||
channel_id = char_state.channel_id
|
||||
|
||||
# TODO: Validate both monsters exist
|
||||
# TODO: Check if target monster is friendly
|
||||
# TODO: Apply damage to target monster
|
||||
# TODO: Broadcast damage packet
|
||||
# TODO: Check for Shammos escort quest logic
|
||||
|
||||
Logger.debug("Mob attack mob: From OID #{mob_from_oid}, to OID #{mob_to_oid}, damage #{damage}, character #{character_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob attack mob: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster escort collision (CP_MOB_ESCORT_COLLISION).
|
||||
|
||||
Used for escort quests where monsters follow a path with nodes.
|
||||
|
||||
Reference: MobHandler.OnMobEscrotCollision()
|
||||
"""
|
||||
def handle_mob_escort_collision(packet, client_pid) do
|
||||
mob_oid = In.decode_int(packet)
|
||||
new_node = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate monster is an escort type
|
||||
# TODO: Update monster's current node
|
||||
# TODO: Check if node triggers dialog
|
||||
# TODO: Check if node is last node (quest complete)
|
||||
|
||||
Logger.debug("Mob escort collision: OID #{mob_oid}, node #{new_node}, character #{character_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob escort collision: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster escort info request (CP_MOB_REQUEST_ESCORT_INFO).
|
||||
|
||||
Client requests path information for an escort monster.
|
||||
|
||||
Reference: MobHandler.OnMobRequestEscortInfo()
|
||||
"""
|
||||
def handle_mob_request_escort_info(packet, client_pid) do
|
||||
mob_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, _character_id, _char_state} ->
|
||||
# TODO: Get monster from map
|
||||
# TODO: Get map node properties
|
||||
# TODO: Send node properties packet to client
|
||||
|
||||
Logger.debug("Mob escort info request: OID #{mob_oid}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle mob escort info request: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
@doc false
|
||||
def get_character_state(client_pid) do
|
||||
Character.get_state_by_client(client_pid)
|
||||
end
|
||||
|
||||
# Broadcast mob movement to other players in the map
|
||||
defp broadcast_mob_move(
|
||||
map_id,
|
||||
channel_id,
|
||||
mob_id,
|
||||
next_attack_possible,
|
||||
action,
|
||||
move_path,
|
||||
controller_id
|
||||
) do
|
||||
# Encode movement data
|
||||
move_path_data = Path.encode(move_path, false)
|
||||
|
||||
# Build movement packet
|
||||
# LP_MobMove packet structure:
|
||||
# - mob_id (int)
|
||||
# - byte (0)
|
||||
# - byte (0)
|
||||
# - next_attack_possible (bool)
|
||||
# - action (byte)
|
||||
# - skill_id (int)
|
||||
# - multi_target (int, 0)
|
||||
# - rand_time (int, 0)
|
||||
# - move_path_data
|
||||
|
||||
# TODO: Build and broadcast actual packet via Map.broadcast_except
|
||||
# For now just log
|
||||
Logger.debug("Broadcasting mob #{mob_id} move to map #{map_id} (controller: #{controller_id})")
|
||||
end
|
||||
end
|
||||
181
lib/odinsea/channel/handler/monster_carnival.ex
Normal file
181
lib/odinsea/channel/handler/monster_carnival.ex
Normal file
@@ -0,0 +1,181 @@
|
||||
defmodule Odinsea.Channel.Handler.MonsterCarnival do
|
||||
@moduledoc """
|
||||
Handles Monster Carnival (CPQ - Carnival Party Quest) operations.
|
||||
|
||||
Ported from: src/handling/channel/handler/MonsterCarnivalHandler.java
|
||||
|
||||
CPQ is a PvP-style party quest where two parties compete:
|
||||
- Summon monsters to send to the opposing team
|
||||
- Use debuff skills on the opposing team
|
||||
- Deploy guardians for defense
|
||||
|
||||
## Tabs
|
||||
- 0: Summon monsters
|
||||
- 1: Use debuff skills
|
||||
- 2: Summon guardians
|
||||
|
||||
## Main Handlers
|
||||
- handle_monster_carnival/2 - All CPQ operations
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# CPQ Operations
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles all Monster Carnival operations (CP_MONSTER_CARNIVAL / 0x125).
|
||||
|
||||
Tabs:
|
||||
- 0: Summon monsters (mob list index)
|
||||
- 1: Use debuff skills (skill list index)
|
||||
- 2: Summon guardians (guardian index)
|
||||
|
||||
Reference: MonsterCarnivalHandler.MonsterCarnival()
|
||||
"""
|
||||
def handle_monster_carnival(packet, client_pid) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Check if in carnival party
|
||||
if char_state.carnival_party == nil do
|
||||
Logger.debug("Monster Carnival rejected: character #{character_id} not in carnival party")
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
else
|
||||
tab = In.decode_byte(packet)
|
||||
num = In.decode_int(packet)
|
||||
|
||||
handle_carnival_tab(tab, num, client_pid, character_id, char_state)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle monster carnival: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Tab Handlers
|
||||
# ============================================================================
|
||||
|
||||
# Tab 0: Summon monsters
|
||||
defp handle_carnival_tab(0, num, client_pid, character_id, char_state) do
|
||||
map_id = char_state.map
|
||||
team = char_state.carnival_party.team
|
||||
available_cp = char_state.carnival_party.available_cp
|
||||
|
||||
# TODO: Get mob list for map
|
||||
# mobs = Map.get_mobs_to_spawn(map_id)
|
||||
|
||||
# TODO: Validate num is valid index
|
||||
# TODO: Check available CP >= mob_cost
|
||||
|
||||
# If valid:
|
||||
# - Spawn monster for opposing team
|
||||
# - Deduct CP
|
||||
# - Update CP displays
|
||||
# - Broadcast summon message
|
||||
|
||||
Logger.debug("CPQ summon mob: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
|
||||
|
||||
# Send enable actions (success or failure)
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Tab 1: Use debuff skills
|
||||
defp handle_carnival_tab(1, num, client_pid, character_id, char_state) do
|
||||
map_id = char_state.map
|
||||
team = char_state.carnival_party.team
|
||||
available_cp = char_state.carnival_party.available_cp
|
||||
|
||||
# TODO: Get skill list for map
|
||||
# skills = Map.get_skill_ids(map_id)
|
||||
|
||||
# TODO: Validate num is valid index
|
||||
# TODO: Get skill from MapleCarnivalFactory
|
||||
# TODO: Check available CP >= skill.cp_loss
|
||||
|
||||
# If valid:
|
||||
# - Apply debuff to opposing team
|
||||
# - Deduct CP
|
||||
# - Update CP displays
|
||||
# - Broadcast skill usage
|
||||
|
||||
Logger.debug("CPQ debuff: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
|
||||
|
||||
# Send enable actions (success or failure)
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Tab 2: Summon guardians
|
||||
defp handle_carnival_tab(2, num, client_pid, character_id, char_state) do
|
||||
map_id = char_state.map
|
||||
team = char_state.carnival_party.team
|
||||
available_cp = char_state.carnival_party.available_cp
|
||||
|
||||
# TODO: Get guardian skill from MapleCarnivalFactory
|
||||
# skill = MapleCarnivalFactory.getGuardian(num)
|
||||
|
||||
# TODO: Check available CP >= skill.cp_loss
|
||||
|
||||
# If valid:
|
||||
# - Spawn carnival reactor (guardian)
|
||||
# - Deduct CP
|
||||
# - Update CP displays
|
||||
# - Broadcast summon message
|
||||
|
||||
Logger.debug("CPQ guardian: index #{num}, team #{team}, map #{map_id}, character #{character_id}")
|
||||
|
||||
# Send enable actions (success or failure)
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# Unknown tab
|
||||
defp handle_carnival_tab(tab, num, client_pid, character_id, _char_state) do
|
||||
Logger.warning("Unknown CPQ tab #{tab} (num #{num}) from character #{character_id}")
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# CP Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Updates CP display for a player.
|
||||
|
||||
party_cp: true = show party CP, false = show personal CP
|
||||
"""
|
||||
def update_cp(client_pid, available_cp, total_cp, team, party_cp \\ false) do
|
||||
# TODO: Build CP update packet
|
||||
# packet = Packets.cp_update(available_cp, total_cp, team, party_cp)
|
||||
# send(client_pid, {:send_packet, packet})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts player summoned message to map.
|
||||
"""
|
||||
def broadcast_summon(map_id, player_name, tab, num) do
|
||||
# TODO: Build summon broadcast packet
|
||||
# packet = Packets.player_summoned(player_name, tab, num)
|
||||
# Map.broadcast_packet(map_id, packet, exclude: player_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Distributes CP to carnival party members.
|
||||
"""
|
||||
def distribute_cp(carnival_party, cp_amount) do
|
||||
# TODO: Add CP to party total
|
||||
# TODO: Update each member's display
|
||||
:ok
|
||||
end
|
||||
end
|
||||
510
lib/odinsea/channel/handler/party.ex
Normal file
510
lib/odinsea/channel/handler/party.ex
Normal file
@@ -0,0 +1,510 @@
|
||||
defmodule Odinsea.Channel.Handler.Party do
|
||||
@moduledoc """
|
||||
Handles party operations.
|
||||
Ported from src/handling/channel/handler/PartyHandler.java
|
||||
|
||||
Manages party create, join, leave, expel, and leader change.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.World.Party
|
||||
|
||||
@party_invite_quest_id 1000 # TODO: Get actual quest ID
|
||||
@party_request_quest_id 1001 # TODO: Get actual quest ID
|
||||
|
||||
@doc """
|
||||
Handles party operations (CP_PARTY_OPERATION).
|
||||
Ported from PartyHandler.PartyOperation()
|
||||
|
||||
Operation:
|
||||
- 1: Create party
|
||||
- 2: Leave party
|
||||
- 3: Accept invitation
|
||||
- 4: Invite player
|
||||
- 5: Expel member
|
||||
- 6: Change leader
|
||||
- 7: Request to join party
|
||||
- 8: Toggle party requests
|
||||
"""
|
||||
def handle_party_operation(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
|
||||
{operation, packet} = In.decode_byte(packet)
|
||||
|
||||
Logger.debug("Party operation: #{operation} from #{character.name}")
|
||||
|
||||
case operation do
|
||||
1 -> handle_create_party(character, client_state)
|
||||
2 -> handle_leave_party(character, client_state)
|
||||
3 -> handle_accept_invitation(packet, character, client_state)
|
||||
4 -> handle_invite_player(packet, character, client_state)
|
||||
5 -> handle_expel_member(packet, character, client_state)
|
||||
6 -> handle_change_leader(packet, character, client_state)
|
||||
7 -> handle_request_join(packet, character, client_state)
|
||||
8 -> handle_toggle_requests(packet, character, client_state)
|
||||
_ ->
|
||||
Logger.warning("Unknown party operation: #{operation}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Party operation failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party request denial (CP_DENY_PARTY_REQUEST).
|
||||
Ported from PartyHandler.DenyPartyRequest()
|
||||
"""
|
||||
def handle_deny_party_request(packet, client_state) do
|
||||
with {:ok, _character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(client_state.character_id) do
|
||||
|
||||
{action, packet} = In.decode_byte(packet)
|
||||
|
||||
# Check for GMS-specific action
|
||||
if action == 0x32 do
|
||||
# TODO: GMS-specific party join
|
||||
{:ok, client_state}
|
||||
else
|
||||
{party_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if action == 0x1D || action == 0x1B do
|
||||
# Accept - handled by PartyOperation(3)
|
||||
{:ok, client_state}
|
||||
else
|
||||
# Deny - notify inviter
|
||||
case Party.get_party(party_id) do
|
||||
nil -> :ok
|
||||
party ->
|
||||
# Find leader and notify
|
||||
notify_party_denied(party.leader_id, character.name)
|
||||
end
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
else
|
||||
_ -> {:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party invite settings (CP_ALLOW_PARTY_INVITE).
|
||||
Ported from PartyHandler.AllowPartyInvite()
|
||||
"""
|
||||
def handle_allow_party_invite(packet, character) do
|
||||
{enabled, _packet} = In.decode_byte(packet)
|
||||
|
||||
# Update quest status for party invite blocking
|
||||
if enabled > 0 do
|
||||
Character.remove_quest(character.id, @party_invite_quest_id)
|
||||
else
|
||||
Character.start_quest(character.id, @party_invite_quest_id)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Party Operation Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_create_party(character, client_state) do
|
||||
if character.party_id && character.party_id > 0 do
|
||||
# Already in a party
|
||||
case Party.get_party(character.party_id) do
|
||||
nil ->
|
||||
# Invalid party, create new
|
||||
create_new_party(character, client_state)
|
||||
|
||||
party ->
|
||||
# Check if leader of single-member party
|
||||
if party.leader_id == character.id && length(party.members) == 1 do
|
||||
# Re-send party created
|
||||
party_created_packet = Packets.party_created(party.id)
|
||||
send_packet(client_state, party_created_packet)
|
||||
else
|
||||
Character.send_message(character.id, "You can't create a party as you are already in one", 5)
|
||||
end
|
||||
end
|
||||
else
|
||||
create_new_party(character, client_state)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp create_new_party(character, client_state) do
|
||||
party_character = create_party_character(character, client_state.channel_id)
|
||||
|
||||
case Party.create_party(party_character) do
|
||||
{:ok, party} ->
|
||||
# Update character's party
|
||||
Character.set_party(character.id, party.id)
|
||||
|
||||
# Send party created packet
|
||||
party_created_packet = Packets.party_created(party.id)
|
||||
send_packet(client_state, party_created_packet)
|
||||
|
||||
Logger.info("Party #{party.id} created by #{character.name}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create party: #{inspect(reason)}")
|
||||
Character.send_message(character.id, "Failed to create party", 5)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_leave_party(character, client_state) do
|
||||
if character.party_id && character.party_id > 0 do
|
||||
party_character = create_party_character(character, client_state.channel_id)
|
||||
|
||||
case Party.update_party(character.party_id, :leave, party_character) do
|
||||
{:ok, _} ->
|
||||
# Update character
|
||||
Character.set_party(character.id, nil)
|
||||
|
||||
# If in Dojo or Pyramid, fail those
|
||||
# TODO: Implement Dojo/Pyramid fail
|
||||
|
||||
# If in event instance, handle leave
|
||||
# TODO: Implement event instance leftParty
|
||||
|
||||
Logger.info("#{character.name} left party #{character.party_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to leave party: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_accept_invitation(packet, character, client_state) do
|
||||
{party_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.party_id && character.party_id > 0 do
|
||||
Character.send_message(character.id, "You can't join the party as you are already in one", 5)
|
||||
else
|
||||
# Check if accepting party invites
|
||||
if Character.has_quest(character.id, @party_invite_quest_id) do
|
||||
{:ok, client_state}
|
||||
else
|
||||
case Party.get_party(party_id) do
|
||||
nil ->
|
||||
Character.send_message(character.id, "The party you are trying to join does not exist", 5)
|
||||
|
||||
party ->
|
||||
if length(party.members) >= 6 do
|
||||
send_party_status_message(client_state, 17)
|
||||
else
|
||||
# Join party
|
||||
party_character = create_party_character(character, client_state.channel_id)
|
||||
|
||||
case Party.update_party(party_id, :join, party_character) do
|
||||
{:ok, _} ->
|
||||
Character.set_party(character.id, party_id)
|
||||
|
||||
# Request party member HP updates
|
||||
# TODO: Implement receivePartyMemberHP / updatePartyMemberHP
|
||||
|
||||
Logger.info("#{character.name} joined party #{party_id}")
|
||||
|
||||
{:error, :party_full} ->
|
||||
send_party_status_message(client_state, 17)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to join party: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_invite_player(packet, character, client_state) do
|
||||
# Create party if not in one
|
||||
party = if not (character.party_id && character.party_id > 0) do
|
||||
party_character = create_party_character(character, client_state.channel_id)
|
||||
{:ok, new_party} = Party.create_party(party_character)
|
||||
Character.set_party(character.id, new_party.id)
|
||||
|
||||
party_created_packet = Packets.party_created(new_party.id)
|
||||
send_packet(client_state, party_created_packet)
|
||||
|
||||
new_party
|
||||
else
|
||||
Party.get_party(character.party_id)
|
||||
end
|
||||
|
||||
{target_name, _packet} = In.decode_string(packet)
|
||||
target_name = String.downcase(target_name)
|
||||
|
||||
cond do
|
||||
party && party.expedition_id > 0 ->
|
||||
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
|
||||
|
||||
party && length(party.members) >= 6 ->
|
||||
send_party_status_message(client_state, 16)
|
||||
|
||||
true ->
|
||||
# Find target character
|
||||
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, target_name) do
|
||||
{:ok, target} ->
|
||||
# Check if can invite
|
||||
if can_invite_to_party?(character, target) do
|
||||
# Send invite
|
||||
send_party_invite(target, character)
|
||||
|
||||
send_party_status_message(client_state, 22, target.name)
|
||||
|
||||
Logger.info("#{character.name} invited #{target.name} to party")
|
||||
else
|
||||
send_party_status_message(client_state, 17)
|
||||
end
|
||||
|
||||
{:error, :not_found} ->
|
||||
send_party_status_message(client_state, 19)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_expel_member(packet, character, client_state) do
|
||||
{target_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.party_id && character.party_id > 0 do
|
||||
case Party.get_party(character.party_id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
party ->
|
||||
# Check if leader
|
||||
if party.leader_id == character.id do
|
||||
# Check expedition
|
||||
if party.expedition_id > 0 do
|
||||
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
|
||||
else
|
||||
# Find member to expel
|
||||
target = Enum.find(party.members, fn m -> m.id == target_id end)
|
||||
|
||||
if target do
|
||||
party_character = %{create_party_character(character, client_state.channel_id) | id: target_id}
|
||||
|
||||
case Party.update_party(character.party_id, :expel, party_character) do
|
||||
{:ok, _} ->
|
||||
# Update expelled character
|
||||
Character.set_party(target_id, nil)
|
||||
|
||||
# Handle event instance
|
||||
# TODO: disbandParty if leader wants to boot
|
||||
|
||||
Logger.info("#{target.name} expelled from party by #{character.name}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to expel member: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_change_leader(packet, character, client_state) do
|
||||
{new_leader_id, _packet} = In.decode_int(packet)
|
||||
|
||||
if character.party_id && character.party_id > 0 do
|
||||
case Party.get_party(character.party_id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
party ->
|
||||
# Check expedition
|
||||
if party.expedition_id > 0 do
|
||||
Character.send_message(character.id, "You may not do party operations while in a raid.", 5)
|
||||
else
|
||||
# Check if leader
|
||||
if party.leader_id == character.id do
|
||||
# Check if new leader is in party
|
||||
if Enum.any?(party.members, fn m -> m.id == new_leader_id end) do
|
||||
case Party.change_leader(character.party_id, new_leader_id, character.id) do
|
||||
:ok ->
|
||||
Logger.info("Party #{character.party_id} leader changed to #{new_leader_id}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to change leader: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_request_join(packet, character, client_state) do
|
||||
{party_id, _packet} = In.decode_int(packet)
|
||||
|
||||
# Leave current party if any
|
||||
if character.party_id && character.party_id > 0 do
|
||||
handle_leave_party(character, client_state)
|
||||
end
|
||||
|
||||
# Request to join party
|
||||
case Party.get_party(party_id) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
party ->
|
||||
# Check restrictions
|
||||
# TODO: Check event instance, pyramid, dojo, expedition
|
||||
|
||||
# Find leader
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, party.leader_id) do
|
||||
[{leader_pid, _}] ->
|
||||
case Character.get_state(leader_pid) do
|
||||
{:ok, leader} ->
|
||||
# Check if leader accepts party requests
|
||||
unless Character.has_quest(leader.id, @party_request_quest_id) do
|
||||
# Check blacklist
|
||||
unless Enum.member?(leader.blacklist, String.downcase(character.name)) do
|
||||
# Send request to leader
|
||||
send_party_request(leader, character)
|
||||
|
||||
send_party_status_message(client_state, 50, character.name)
|
||||
else
|
||||
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
|
||||
end
|
||||
else
|
||||
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
|
||||
end
|
||||
|
||||
_ ->
|
||||
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
|
||||
end
|
||||
|
||||
[] ->
|
||||
Character.send_message(character.id, "Player was not found or player is not accepting party requests.", 5)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_toggle_requests(packet, character, client_state) do
|
||||
{enabled, _packet} = In.decode_byte(packet)
|
||||
|
||||
if enabled > 0 do
|
||||
Character.remove_quest(character.id, @party_request_quest_id)
|
||||
else
|
||||
Character.start_quest(character.id, @party_request_quest_id)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil -> {:error, :no_character}
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp create_party_character(character, channel_id) do
|
||||
%{
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
level: character.level,
|
||||
job: character.job,
|
||||
channel_id: channel_id,
|
||||
map_id: character.map_id,
|
||||
# Door info (for mystic door skill)
|
||||
door_town: 999999999,
|
||||
door_target: 999999999,
|
||||
door_skill: 0,
|
||||
door_x: 0,
|
||||
door_y: 0
|
||||
}
|
||||
end
|
||||
|
||||
defp can_invite_to_party?(inviter, target) do
|
||||
cond do
|
||||
# Target has blocked inventory
|
||||
target.has_blocked_inventory ->
|
||||
false
|
||||
|
||||
# Target already in party
|
||||
target.party_id && target.party_id > 0 ->
|
||||
false
|
||||
|
||||
# Target has blocked invites
|
||||
Character.has_quest(target.id, @party_invite_quest_id) ->
|
||||
false
|
||||
|
||||
# Target has inviter blacklisted
|
||||
Enum.member?(target.blacklist, String.downcase(inviter.name)) ->
|
||||
false
|
||||
|
||||
true ->
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
defp send_party_invite(target, inviter) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, target.id) do
|
||||
[{pid, _}] ->
|
||||
invite_packet = Packets.party_invite(inviter)
|
||||
send(pid, {:send_packet, invite_packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp send_party_request(leader, requester) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, leader.id) do
|
||||
[{pid, _}] ->
|
||||
request_packet = Packets.party_request(requester)
|
||||
send(pid, {:send_packet, request_packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_party_denied(leader_id, denier_name) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, leader_id) do
|
||||
[{pid, _}] ->
|
||||
message_packet = Packets.party_status_message(23, denier_name)
|
||||
send(pid, {:send_packet, message_packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp send_party_status_message(client_state, code, name \\ "") do
|
||||
packet = Packets.party_status_message(code, name)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp send_packet(client_state, packet) do
|
||||
if client_state.socket do
|
||||
:gen_tcp.send(client_state.socket, packet)
|
||||
end
|
||||
end
|
||||
end
|
||||
528
lib/odinsea/channel/handler/pet.ex
Normal file
528
lib/odinsea/channel/handler/pet.ex
Normal file
@@ -0,0 +1,528 @@
|
||||
defmodule Odinsea.Channel.Handler.Pet do
|
||||
@moduledoc """
|
||||
Handles pet-related packets.
|
||||
Ported from src/handling/channel/handler/PetHandler.java
|
||||
|
||||
Handles:
|
||||
- Pet spawning/despawning
|
||||
- Pet movement
|
||||
- Pet commands (tricks)
|
||||
- Pet chat
|
||||
- Pet food (feeding)
|
||||
- Pet auto-potion
|
||||
- Pet item looting
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Game.{Character, Pet, PetData}
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Pet Spawning
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet spawn request (CP_SpawnPet).
|
||||
Ported from PetHandler.SpawnPet()
|
||||
"""
|
||||
def handle_spawn_pet(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{slot, packet} = In.decode_byte(packet)
|
||||
{lead, _packet} = In.decode_byte(packet)
|
||||
|
||||
Logger.info("Pet spawn request: character=#{character.name}, slot=#{slot}, lead=#{lead}")
|
||||
|
||||
# Get pet from inventory and spawn it
|
||||
case Character.spawn_pet(character_pid, slot, lead > 0) do
|
||||
{:ok, pet} ->
|
||||
Logger.info("Pet spawned: #{pet.name} (level #{pet.level})")
|
||||
|
||||
# Broadcast pet spawn to map
|
||||
spawn_packet = Packets.spawn_pet(character.id, pet, false, false)
|
||||
broadcast_to_map(character.map_id, character.id, spawn_packet, client_state)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to spawn pet: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Spawn pet failed: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles pet despawn.
|
||||
"""
|
||||
def handle_despawn_pet(pet_index, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
case Character.despawn_pet(character_pid, pet_index) do
|
||||
{:ok, pet} ->
|
||||
Logger.info("Pet despawned: #{pet.name}")
|
||||
|
||||
# Broadcast pet removal to map
|
||||
remove_packet = Packets.remove_pet(character.id, pet_index)
|
||||
broadcast_to_map(character.map_id, character.id, remove_packet, client_state)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to despawn pet: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Despawn pet failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Movement
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet movement (CP_MovePet).
|
||||
Ported from PetHandler.MovePet()
|
||||
"""
|
||||
def handle_move_pet(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode pet ID/slot
|
||||
{pet_id_or_slot, packet} = decode_pet_id(packet, Odinsea.Constants.Game.gms?())
|
||||
|
||||
# Skip field key check bytes
|
||||
{_, packet} = In.skip(packet, 8)
|
||||
|
||||
# Get movement data (binary blob to forward to other clients)
|
||||
# In full implementation, parse and validate movement
|
||||
movement_data = packet
|
||||
|
||||
pet_slot = if Odinsea.Constants.Game.gms?(), do: pet_id_or_slot, else: pet_id_or_slot
|
||||
|
||||
# Get the pet
|
||||
case Character.get_pet(character_pid, pet_slot) do
|
||||
{:ok, pet} ->
|
||||
# Update pet position (in full implementation)
|
||||
# Character.update_pet_position(character_pid, pet_slot, new_position)
|
||||
|
||||
# Broadcast movement to other players
|
||||
move_packet = Packets.move_pet(character.id, pet.unique_id, pet_slot, movement_data)
|
||||
broadcast_to_map(character.map_id, character.id, move_packet, client_state)
|
||||
|
||||
# Check for item pickup if pet has pickup ability
|
||||
if Pet.has_flag?(pet, PetData.PetFlag.item_pickup()) do
|
||||
check_pet_loot(character, pet, pet_slot, client_state)
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Move pet failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Chat
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet chat (CP_PetChat).
|
||||
Ported from PetHandler.PetChat()
|
||||
"""
|
||||
def handle_pet_chat(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{pet_slot, packet} = decode_pet_slot(packet)
|
||||
{chat_command, packet} = In.decode_short(packet)
|
||||
{text, _packet} = In.decode_string(packet)
|
||||
|
||||
# Validate pet exists
|
||||
case Character.get_pet(character_pid, pet_slot) do
|
||||
{:ok, _pet} ->
|
||||
Logger.debug("Pet chat: #{character.name}'s pet says: #{text}")
|
||||
|
||||
# Broadcast chat to map
|
||||
chat_packet = Packets.pet_chat(character.id, pet_slot, chat_command, text)
|
||||
broadcast_to_map(character.map_id, character.id, chat_packet, client_state)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet chat failed - no pet at slot #{pet_slot}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet chat failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Commands (Tricks)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet command/trick (CP_PetCommand).
|
||||
Ported from PetHandler.PetCommand()
|
||||
"""
|
||||
def handle_pet_command(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{pet_slot, packet} = decode_pet_slot(packet)
|
||||
{command_id, _packet} = In.decode_byte(packet)
|
||||
|
||||
Logger.debug("Pet command: character=#{character.name}, slot=#{pet_slot}, cmd=#{command_id}")
|
||||
|
||||
case Character.get_pet(character_pid, pet_slot) do
|
||||
{:ok, pet} ->
|
||||
# Get command data
|
||||
case PetData.get_pet_command(pet.pet_item_id, command_id) do
|
||||
{probability, closeness_inc} ->
|
||||
# Roll for success
|
||||
success = :rand.uniform(100) <= probability
|
||||
|
||||
{_result, updated_pet} =
|
||||
if success do
|
||||
# Add closeness on success
|
||||
case Pet.add_closeness(pet, closeness_inc) do
|
||||
{:level_up, leveled_pet} ->
|
||||
# Send level up packets
|
||||
own_level_packet = Packets.show_own_pet_level_up(pet_slot)
|
||||
send_packet(client_state, own_level_packet)
|
||||
|
||||
other_level_packet = Packets.show_pet_level_up(character.id, pet_slot)
|
||||
broadcast_to_map(character.map_id, character.id, other_level_packet, client_state)
|
||||
|
||||
{:level_up, leveled_pet}
|
||||
|
||||
{:ok, updated} ->
|
||||
{:ok, updated}
|
||||
end
|
||||
else
|
||||
{:fail, pet}
|
||||
end
|
||||
|
||||
# Save pet if changed
|
||||
if updated_pet.changed do
|
||||
Character.update_pet(character_pid, updated_pet)
|
||||
|
||||
# Send pet update packet
|
||||
update_packet = Packets.update_pet(updated_pet)
|
||||
send_packet(client_state, update_packet)
|
||||
end
|
||||
|
||||
# Send command response
|
||||
response_packet =
|
||||
Packets.pet_command_response(
|
||||
character.id,
|
||||
pet_slot,
|
||||
command_id,
|
||||
success,
|
||||
false
|
||||
)
|
||||
|
||||
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
|
||||
|
||||
nil ->
|
||||
# Unknown command
|
||||
Logger.warning("Unknown pet command #{command_id} for pet #{pet.pet_item_id}")
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet command failed - no pet at slot #{pet_slot}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet command failed: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Food
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet food (CP_PetFood).
|
||||
Ported from PetHandler.PetFood()
|
||||
"""
|
||||
def handle_pet_food(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{slot, packet} = In.decode_short(packet)
|
||||
{item_id, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.debug("Pet food: item=#{item_id}, slot=#{slot}")
|
||||
|
||||
# Find the hungriest summoned pet
|
||||
case find_hungriest_pet(character_pid) do
|
||||
{:ok, {pet_slot, pet}} ->
|
||||
# Validate food item
|
||||
if PetData.pet_food?(item_id) do
|
||||
# Calculate fullness gain
|
||||
food_value = PetData.get_food_value(item_id)
|
||||
|
||||
# 50% chance to gain closeness when feeding
|
||||
gain_closeness = :rand.uniform(100) <= 50
|
||||
|
||||
if pet.fullness < 100 do
|
||||
# Pet was hungry, feed it
|
||||
updated_pet = Pet.add_fullness(pet, food_value)
|
||||
|
||||
# Possibly add closeness
|
||||
{_closeness_result, final_pet} =
|
||||
if gain_closeness do
|
||||
case Pet.add_closeness(updated_pet, 1) do
|
||||
{:level_up, leveled_pet} ->
|
||||
own_level_packet = Packets.show_own_pet_level_up(pet_slot)
|
||||
send_packet(client_state, own_level_packet)
|
||||
|
||||
other_level_packet = Packets.show_pet_level_up(character.id, pet_slot)
|
||||
broadcast_to_map(character.map_id, character.id, other_level_packet, client_state)
|
||||
|
||||
{:level_up, leveled_pet}
|
||||
|
||||
{:ok, updated} ->
|
||||
{:ok, updated}
|
||||
end
|
||||
else
|
||||
{:ok, updated_pet}
|
||||
end
|
||||
|
||||
# Save pet
|
||||
Character.update_pet(character_pid, final_pet)
|
||||
|
||||
# Send update packet
|
||||
update_packet = Packets.update_pet(final_pet)
|
||||
send_packet(client_state, update_packet)
|
||||
|
||||
# Send command response (food success)
|
||||
response_packet =
|
||||
Packets.pet_command_response(
|
||||
character.id,
|
||||
pet_slot,
|
||||
1,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
|
||||
else
|
||||
# Pet was full, may lose closeness
|
||||
final_pet =
|
||||
if gain_closeness do
|
||||
case Pet.remove_closeness(pet, 1) do
|
||||
{:level_down, downgraded_pet} ->
|
||||
Character.update_pet(character_pid, downgraded_pet)
|
||||
downgraded_pet
|
||||
|
||||
{:ok, updated} ->
|
||||
Character.update_pet(character_pid, updated)
|
||||
updated
|
||||
end
|
||||
else
|
||||
pet
|
||||
end
|
||||
|
||||
# Send update
|
||||
update_packet = Packets.update_pet(final_pet)
|
||||
send_packet(client_state, update_packet)
|
||||
|
||||
# Send failure response
|
||||
response_packet =
|
||||
Packets.pet_command_response(
|
||||
character.id,
|
||||
pet_slot,
|
||||
1,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
broadcast_to_map(character.map_id, character.id, response_packet, client_state)
|
||||
end
|
||||
|
||||
# Remove food from inventory
|
||||
# Character.remove_item(character_pid, :use, slot, 1)
|
||||
else
|
||||
Logger.warning("Invalid pet food item: #{item_id}")
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet food failed: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet food failed: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Auto-Potion
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet auto-potion (CP_PetAutoPot).
|
||||
Ported from PetHandler.Pet_AutoPotion()
|
||||
"""
|
||||
def handle_pet_auto_potion(packet, client_state) do
|
||||
# Skip field key bytes
|
||||
{_, packet} = In.skip(packet, if(Odinsea.Constants.Game.gms?(), do: 9, else: 1))
|
||||
|
||||
# Decode packet
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{slot, packet} = In.decode_short(packet)
|
||||
{item_id, _packet} = In.decode_int(packet)
|
||||
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, _character} <- Character.get_state(character_pid) do
|
||||
# TODO: Validate item and use potion
|
||||
# This requires checking if HP/MP is below threshold and using the item
|
||||
Logger.debug("Pet auto-potion: slot=#{slot}, item=#{item_id}")
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet auto-potion failed: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Loot
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles pet looting (CP_PetLoot).
|
||||
"""
|
||||
def handle_pet_loot(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{pet_slot, _packet} = decode_pet_slot(packet)
|
||||
|
||||
case Character.get_pet(character_pid, pet_slot) do
|
||||
{:ok, pet} ->
|
||||
if Pet.has_flag?(pet, PetData.PetFlag.item_pickup()) do
|
||||
# Attempt to loot nearby items
|
||||
check_pet_loot(character, pet, pet_slot, client_state)
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Pet loot failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp find_hungriest_pet(character_pid) do
|
||||
# Get all summoned pets and find the one with lowest fullness
|
||||
case Character.get_summoned_pets(character_pid) do
|
||||
[] ->
|
||||
{:error, :no_pets_summoned}
|
||||
|
||||
pets ->
|
||||
{slot, pet} = Enum.min_by(pets, fn {_slot, p} -> p.fullness end)
|
||||
{:ok, {slot, pet}}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_pet_loot(_character, pet, pet_slot, _client_state) do
|
||||
# Get items near the pet
|
||||
# In full implementation, query map for items in range
|
||||
# For now, this is a placeholder
|
||||
Logger.debug("Checking pet loot for #{pet.name} at slot #{pet_slot}")
|
||||
|
||||
# Pickup range check
|
||||
# If item is in range and pet has appropriate flags, pick it up
|
||||
:ok
|
||||
end
|
||||
|
||||
# Decodes pet ID/slot based on GMS mode
|
||||
defp decode_pet_id(packet, true = _gms) do
|
||||
In.decode_byte(packet)
|
||||
end
|
||||
|
||||
defp decode_pet_id(packet, false = _gms) do
|
||||
In.decode_int(packet)
|
||||
end
|
||||
|
||||
# Decodes pet slot based on GMS mode
|
||||
defp decode_pet_slot(packet) do
|
||||
if Odinsea.Constants.Game.gms?() do
|
||||
In.decode_byte(packet)
|
||||
else
|
||||
In.decode_int(packet)
|
||||
end
|
||||
end
|
||||
|
||||
# Gets character PID from client state
|
||||
defp get_character(client_state) do
|
||||
if client_state[:character_pid] do
|
||||
{:ok, client_state.character_pid}
|
||||
else
|
||||
{:error, :no_character}
|
||||
end
|
||||
end
|
||||
|
||||
# Sends a packet to the client
|
||||
defp send_packet(client_state, packet_data) do
|
||||
if client_state[:transport] && client_state[:client_pid] do
|
||||
send(client_state.client_pid, {:send_packet, packet_data})
|
||||
end
|
||||
end
|
||||
|
||||
# Broadcasts a packet to all players on the map except the sender
|
||||
defp broadcast_to_map(map_id, character_id, packet_data, client_state) do
|
||||
# In full implementation, get map PID and broadcast
|
||||
# For now, placeholder
|
||||
if client_state[:channel_id] do
|
||||
Odinsea.Game.Map.broadcast_except(map_id, client_state.channel_id, character_id, packet_data)
|
||||
end
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
# Sends enable actions packet to client
|
||||
defp send_enable_actions(client_state) do
|
||||
packet = Packets.enable_actions()
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.{Character, Movement, Map}
|
||||
alias Odinsea.Game.{Character, Movement, Map, AttackInfo, DamageCalc}
|
||||
|
||||
@doc """
|
||||
Handles player movement (CP_MOVE_PLAYER).
|
||||
@@ -31,16 +31,22 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
# Store original position
|
||||
original_pos = character.position
|
||||
|
||||
# Parse movement
|
||||
case Movement.parse_movement(packet) do
|
||||
{:ok, movement_data, final_pos} ->
|
||||
# Parse movement using the full movement system
|
||||
case Movement.parse_player_movement(packet, original_pos) do
|
||||
{:ok, movements, final_pos} ->
|
||||
# Update character position
|
||||
Character.update_position(character_pid, final_pos)
|
||||
|
||||
# Serialize movements for broadcast
|
||||
movement_data = Movement.serialize_movements(movements)
|
||||
|
||||
# Broadcast movement to other players
|
||||
move_packet =
|
||||
Out.new(Opcodes.lp_move_player())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_short(original_pos.x)
|
||||
|> Out.encode_short(original_pos.y)
|
||||
|> Out.encode_int(0) # Unknown int
|
||||
|> Out.encode_bytes(movement_data)
|
||||
|> Out.to_data()
|
||||
|
||||
@@ -52,7 +58,7 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
)
|
||||
|
||||
Logger.debug(
|
||||
"Player #{character.name} moved to (#{final_pos.x}, #{final_pos.y})"
|
||||
"Player #{character.name} moved from (#{original_pos.x}, #{original_pos.y}) to (#{final_pos.x}, #{final_pos.y}) with #{length(movements)} movements"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
@@ -168,20 +174,39 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
|
||||
@doc """
|
||||
Handles close-range attack (CP_CLOSE_RANGE_ATTACK).
|
||||
Ported from PlayerHandler.closeRangeAttack() - STUB for now
|
||||
Ported from PlayerHandler.closeRangeAttack() and DamageParse.parseDmgM()
|
||||
"""
|
||||
def handle_close_range_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Close range attack from #{character.name} (stub)")
|
||||
# TODO: Implement attack logic
|
||||
# - Parse attack info
|
||||
# - Validate attack
|
||||
# - Calculate damage
|
||||
# - Apply damage to mobs
|
||||
# - Broadcast attack packet
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Parse attack packet
|
||||
case AttackInfo.parse_melee_attack(packet) do
|
||||
{:ok, attack_info} ->
|
||||
Logger.debug(
|
||||
"Close range attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
# Apply attack via DamageCalc
|
||||
case DamageCalc.apply_attack(
|
||||
attack_info,
|
||||
character_pid,
|
||||
map_pid,
|
||||
client_state.channel_id
|
||||
) do
|
||||
{:ok, total_damage} ->
|
||||
Logger.debug("Attack dealt #{total_damage} total damage")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to parse melee attack: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Close range attack failed: #{inspect(reason)}")
|
||||
@@ -191,15 +216,39 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
|
||||
@doc """
|
||||
Handles ranged attack (CP_RANGED_ATTACK).
|
||||
Ported from PlayerHandler.rangedAttack() - STUB for now
|
||||
Ported from PlayerHandler.rangedAttack() and DamageParse.parseDmgR()
|
||||
"""
|
||||
def handle_ranged_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Ranged attack from #{character.name} (stub)")
|
||||
# TODO: Implement ranged attack logic
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Parse attack packet
|
||||
case AttackInfo.parse_ranged_attack(packet) do
|
||||
{:ok, attack_info} ->
|
||||
Logger.debug(
|
||||
"Ranged attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
# Apply attack via DamageCalc
|
||||
case DamageCalc.apply_attack(
|
||||
attack_info,
|
||||
character_pid,
|
||||
map_pid,
|
||||
client_state.channel_id
|
||||
) do
|
||||
{:ok, total_damage} ->
|
||||
Logger.debug("Attack dealt #{total_damage} total damage")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to parse ranged attack: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Ranged attack failed: #{inspect(reason)}")
|
||||
@@ -209,15 +258,39 @@ defmodule Odinsea.Channel.Handler.Player do
|
||||
|
||||
@doc """
|
||||
Handles magic attack (CP_MAGIC_ATTACK).
|
||||
Ported from PlayerHandler.MagicDamage() - STUB for now
|
||||
Ported from PlayerHandler.MagicDamage() and DamageParse.parseDmgMa()
|
||||
"""
|
||||
def handle_magic_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Magic attack from #{character.name} (stub)")
|
||||
# TODO: Implement magic attack logic
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Parse attack packet
|
||||
case AttackInfo.parse_magic_attack(packet) do
|
||||
{:ok, attack_info} ->
|
||||
Logger.debug(
|
||||
"Magic attack from #{character.name}: skill=#{attack_info.skill}, targets=#{attack_info.targets}, hits=#{attack_info.hits}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
# Apply attack via DamageCalc
|
||||
case DamageCalc.apply_attack(
|
||||
attack_info,
|
||||
character_pid,
|
||||
map_pid,
|
||||
client_state.channel_id
|
||||
) do
|
||||
{:ok, total_damage} ->
|
||||
Logger.debug("Attack dealt #{total_damage} total damage")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to parse magic attack: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Magic attack failed: #{inspect(reason)}")
|
||||
|
||||
973
lib/odinsea/channel/handler/player_shop.ex
Normal file
973
lib/odinsea/channel/handler/player_shop.ex
Normal file
@@ -0,0 +1,973 @@
|
||||
defmodule Odinsea.Channel.Handler.PlayerShop do
|
||||
@moduledoc """
|
||||
Handles player shop and hired merchant packets.
|
||||
Ported from:
|
||||
- src/handling/channel/handler/PlayerInteractionHandler.java
|
||||
- src/handling/channel/handler/HiredMerchantHandler.java
|
||||
|
||||
Handles:
|
||||
- Creating player shops and mini games
|
||||
- Visiting shops
|
||||
- Buying/selling items
|
||||
- Managing visitors
|
||||
- Mini game operations (Omok, Match Card)
|
||||
- Hired merchant operations
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Game.{PlayerShop, HiredMerchant, MiniGame, ShopItem, Item, Equip}
|
||||
|
||||
# Interaction action constants (from PlayerInteractionHandler.Interaction enum)
|
||||
# GMS v342 values
|
||||
@action_create 0x06
|
||||
@action_invite_trade 0x11
|
||||
@action_deny_trade 0x12
|
||||
@action_visit 0x09
|
||||
@action_chat 0x14
|
||||
@action_exit 0x18
|
||||
@action_open 0x16
|
||||
@action_set_items 0x00
|
||||
@action_set_meso 0x01
|
||||
@action_confirm_trade 0x02
|
||||
@action_player_shop_add_item 0x28
|
||||
@action_buy_item_player_shop 0x22
|
||||
@action_add_item 0x23
|
||||
@action_buy_item_store 0x24
|
||||
@action_buy_item_hired_merchant 0x26
|
||||
@action_remove_item 0x28
|
||||
@action_maintenance_off 0x29
|
||||
@action_maintenance_organise 0x30
|
||||
@action_close_merchant 0x31
|
||||
@action_admin_store_namechange 0x35
|
||||
@action_view_merchant_visitor 0x36
|
||||
@action_view_merchant_blacklist 0x37
|
||||
@action_merchant_blacklist_add 0x38
|
||||
@action_merchant_blacklist_remove 0x39
|
||||
@action_request_tie 0x51
|
||||
@action_answer_tie 0x52
|
||||
@action_give_up 0x53
|
||||
@action_request_redo 0x55
|
||||
@action_answer_redo 0x56
|
||||
@action_exit_after_game 0x57
|
||||
@action_cancel_exit 0x58
|
||||
@action_ready 0x59
|
||||
@action_un_ready 0x60
|
||||
@action_expel 0x61
|
||||
@action_start 0x62
|
||||
@action_skip 0x64
|
||||
@action_move_omok 0x65
|
||||
@action_select_card 0x68
|
||||
|
||||
# Create type constants
|
||||
@create_type_trade 3
|
||||
@create_type_omok 1
|
||||
@create_type_match_card 2
|
||||
@create_type_player_shop 4
|
||||
@create_type_hired_merchant 5
|
||||
|
||||
@doc """
|
||||
Main handler for player interaction packets.
|
||||
"""
|
||||
def handle_interaction(packet, client_state) do
|
||||
{action, packet} = In.decode_byte(packet)
|
||||
|
||||
case action do
|
||||
@action_create -> handle_create(packet, client_state)
|
||||
@action_visit -> handle_visit(packet, client_state)
|
||||
@action_chat -> handle_chat(packet, client_state)
|
||||
@action_exit -> handle_exit(packet, client_state)
|
||||
@action_open -> handle_open(packet, client_state)
|
||||
@action_player_shop_add_item -> handle_add_item(packet, client_state)
|
||||
@action_add_item -> handle_add_item(packet, client_state)
|
||||
@action_buy_item_player_shop -> handle_buy_item(packet, client_state)
|
||||
@action_buy_item_store -> handle_buy_item(packet, client_state)
|
||||
@action_buy_item_hired_merchant -> handle_buy_item(packet, client_state)
|
||||
@action_remove_item -> handle_remove_item(packet, client_state)
|
||||
@action_maintenance_off -> handle_maintenance_off(packet, client_state)
|
||||
@action_maintenance_organise -> handle_maintenance_organise(packet, client_state)
|
||||
@action_close_merchant -> handle_close_merchant(packet, client_state)
|
||||
@action_view_merchant_visitor -> handle_view_visitors(packet, client_state)
|
||||
@action_view_merchant_blacklist -> handle_view_blacklist(packet, client_state)
|
||||
@action_merchant_blacklist_add -> handle_blacklist_add(packet, client_state)
|
||||
@action_merchant_blacklist_remove -> handle_blacklist_remove(packet, client_state)
|
||||
@action_ready -> handle_ready(packet, client_state)
|
||||
@action_un_ready -> handle_ready(packet, client_state)
|
||||
@action_start -> handle_start_game(packet, client_state)
|
||||
@action_give_up -> handle_give_up(packet, client_state)
|
||||
@action_request_tie -> handle_request_tie(packet, client_state)
|
||||
@action_answer_tie -> handle_answer_tie(packet, client_state)
|
||||
@action_skip -> handle_skip(packet, client_state)
|
||||
@action_move_omok -> handle_move_omok(packet, client_state)
|
||||
@action_select_card -> handle_select_card(packet, client_state)
|
||||
@action_exit_after_game -> handle_exit_after_game(packet, client_state)
|
||||
@action_cancel_exit -> handle_exit_after_game(packet, client_state)
|
||||
_ ->
|
||||
Logger.debug("Unhandled player interaction action: #{action}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles hired merchant specific packets.
|
||||
"""
|
||||
def handle_hired_merchant(packet, client_state) do
|
||||
{operation, packet} = In.decode_byte(packet)
|
||||
|
||||
case operation do
|
||||
# Display Fredrick/Merchant item store
|
||||
20 -> handle_display_merch(client_state)
|
||||
# Open merch item store
|
||||
25 -> handle_open_merch_store(client_state)
|
||||
# Retrieve items
|
||||
26 -> handle_retrieve_items(packet, client_state)
|
||||
# Close dialog
|
||||
27 ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
_ ->
|
||||
Logger.debug("Unhandled hired merchant operation: #{operation}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Create Shop/Game Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_create(packet, client_state) do
|
||||
{create_type, packet} = In.decode_byte(packet)
|
||||
{description, packet} = In.decode_string(packet)
|
||||
{has_password, packet} = In.decode_byte(packet)
|
||||
|
||||
password =
|
||||
if has_password > 0 do
|
||||
{pwd, packet} = In.decode_string(packet)
|
||||
pwd
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
case create_type do
|
||||
@create_type_omok ->
|
||||
{piece, _packet} = In.decode_byte(packet)
|
||||
create_mini_game(client_state, description, password, MiniGame.game_type_omok(), piece)
|
||||
|
||||
@create_type_match_card ->
|
||||
{piece, _packet} = In.decode_byte(packet)
|
||||
create_mini_game(client_state, description, password, MiniGame.game_type_match_card(), piece)
|
||||
|
||||
@create_type_player_shop ->
|
||||
# Skip slot and item ID validation for now
|
||||
create_player_shop(client_state, description)
|
||||
|
||||
@create_type_hired_merchant ->
|
||||
create_hired_merchant(client_state, description)
|
||||
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_mini_game(client_state, description, password, game_type, piece_type) do
|
||||
with {:ok, character} <- get_character(client_state) do
|
||||
game_opts = %{
|
||||
id: generate_id(),
|
||||
owner_id: character.id,
|
||||
owner_name: character.name,
|
||||
description: description,
|
||||
password: password,
|
||||
game_type: game_type,
|
||||
piece_type: piece_type,
|
||||
map_id: character.map_id,
|
||||
channel: client_state.channel
|
||||
}
|
||||
|
||||
# Start the mini game GenServer
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MiniGameSupervisor,
|
||||
{MiniGame, game_opts}
|
||||
) do
|
||||
{:ok, _pid} ->
|
||||
# Send mini game packet
|
||||
packet = encode_mini_game(game_opts)
|
||||
send_packet(client_state, packet)
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, %{client_state | player_shop: game_opts.id}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create mini game: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_player_shop(client_state, description) do
|
||||
with {:ok, character} <- get_character(client_state) do
|
||||
shop_opts = %{
|
||||
id: generate_id(),
|
||||
owner_id: character.id,
|
||||
owner_account_id: character.account_id,
|
||||
owner_name: character.name,
|
||||
item_id: 5_040_000,
|
||||
description: description,
|
||||
map_id: character.map_id,
|
||||
channel: client_state.channel
|
||||
}
|
||||
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.ShopSupervisor,
|
||||
{PlayerShop, shop_opts}
|
||||
) do
|
||||
{:ok, _pid} ->
|
||||
# Send player shop packet
|
||||
packet = encode_player_shop(shop_opts, true)
|
||||
send_packet(client_state, packet)
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, %{client_state | player_shop: shop_opts.id}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create player shop: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_hired_merchant(client_state, description) do
|
||||
with {:ok, character} <- get_character(client_state) do
|
||||
# Check if already has a merchant
|
||||
# In full implementation, check world for existing merchant
|
||||
|
||||
merchant_opts = %{
|
||||
id: generate_id(),
|
||||
owner_id: character.id,
|
||||
owner_account_id: character.account_id,
|
||||
owner_name: character.name,
|
||||
item_id: 5_030_000,
|
||||
description: description,
|
||||
map_id: character.map_id,
|
||||
channel: client_state.channel
|
||||
}
|
||||
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MerchantSupervisor,
|
||||
{HiredMerchant, merchant_opts}
|
||||
) do
|
||||
{:ok, _pid} ->
|
||||
# Send hired merchant packet
|
||||
packet = encode_hired_merchant(merchant_opts, true)
|
||||
send_packet(client_state, packet)
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, %{client_state | player_shop: merchant_opts.id}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create hired merchant: #{inspect(reason)}")
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Visit/Exit Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_visit(packet, client_state) do
|
||||
{object_id, packet} = In.decode_int(packet)
|
||||
|
||||
# Try to find shop by object ID
|
||||
# This would need proper map object tracking
|
||||
|
||||
# For now, simplified version
|
||||
Logger.debug("Visit shop: object_id=#{object_id}")
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_exit(_packet, client_state) do
|
||||
# Close player shop or mini game
|
||||
if client_state.player_shop do
|
||||
# Clean up
|
||||
{:ok, %{client_state | player_shop: nil}}
|
||||
else
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Shop Management Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_open(_packet, client_state) do
|
||||
with {:ok, character} <- get_character(client_state),
|
||||
shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
# Try player shop first, then hired merchant
|
||||
case PlayerShop.set_open(shop_id, true) do
|
||||
:ok ->
|
||||
PlayerShop.set_available(shop_id, true)
|
||||
:ok
|
||||
|
||||
{:error, :not_found} ->
|
||||
HiredMerchant.set_open(shop_id, true)
|
||||
HiredMerchant.set_available(shop_id, true)
|
||||
:ok
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_add_item(packet, client_state) do
|
||||
{inv_type, packet} = In.decode_byte(packet)
|
||||
{slot, packet} = In.decode_short(packet)
|
||||
{bundles, packet} = In.decode_short(packet)
|
||||
{per_bundle, packet} = In.decode_short(packet)
|
||||
{price, _packet} = In.decode_int(packet)
|
||||
|
||||
with {:ok, character} <- get_character(client_state),
|
||||
shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
# Get item from inventory
|
||||
# Create shop item
|
||||
item = %ShopItem{
|
||||
item: %Item{item_id: 400_0000, quantity: per_bundle},
|
||||
bundles: bundles,
|
||||
price: price
|
||||
}
|
||||
|
||||
# Add to shop
|
||||
case PlayerShop.add_item(shop_id, item) do
|
||||
:ok ->
|
||||
# Send item update packet
|
||||
send_shop_item_update(client_state, shop_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_buy_item(packet, client_state) do
|
||||
{item_slot, packet} = In.decode_byte(packet)
|
||||
{quantity, _packet} = In.decode_short(packet)
|
||||
|
||||
with {:ok, character} <- get_character(client_state),
|
||||
shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
# Try player shop buy
|
||||
case PlayerShop.buy_item(shop_id, item_slot, quantity, character.id, character.name) do
|
||||
{:ok, item, price, status} ->
|
||||
# Deduct meso and add item
|
||||
# Send update packet
|
||||
send_shop_item_update(client_state, shop_id)
|
||||
|
||||
if status == :close do
|
||||
# Shop closed (all items sold)
|
||||
send_shop_error_message(client_state, 10, 1)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("Buy item failed: #{reason}")
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_remove_item(packet, client_state) do
|
||||
{slot, _packet} = In.decode_short(packet)
|
||||
|
||||
with {:ok, _character} <- get_character(client_state),
|
||||
shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
case PlayerShop.remove_item(shop_id, slot) do
|
||||
{:ok, _item} ->
|
||||
send_shop_item_update(client_state, shop_id)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
else
|
||||
_ ->
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Hired Merchant Specific Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_maintenance_off(_packet, client_state) do
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
HiredMerchant.set_open(shop_id, true)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_maintenance_organise(_packet, client_state) do
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
# Clean up sold out items and give meso
|
||||
# This is a simplified version
|
||||
:ok
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_close_merchant(_packet, client_state) do
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
HiredMerchant.close_merchant(shop_id, true, true)
|
||||
|
||||
# Send Fredrick message
|
||||
send_drop_message(client_state, 1, "Please visit Fredrick for your items.")
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, %{client_state | player_shop: nil}}
|
||||
end
|
||||
|
||||
defp handle_view_visitors(_packet, client_state) do
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil,
|
||||
visitors <- HiredMerchant.get_visitors(shop_id) do
|
||||
packet = encode_visitor_view(visitors)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_view_blacklist(_packet, client_state) do
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil,
|
||||
blacklist <- HiredMerchant.get_blacklist(shop_id) do
|
||||
packet = encode_blacklist_view(blacklist)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_blacklist_add(packet, client_state) do
|
||||
{name, _packet} = In.decode_string(packet)
|
||||
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
HiredMerchant.add_to_blacklist(shop_id, name)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_blacklist_remove(packet, client_state) do
|
||||
{name, _packet} = In.decode_string(packet)
|
||||
|
||||
with shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
HiredMerchant.remove_from_blacklist(shop_id, name)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Mini Game Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_ready(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
MiniGame.set_ready(game_id, character.id)
|
||||
# Send ready update
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_start_game(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil do
|
||||
case MiniGame.start_game(game_id) do
|
||||
{:ok, loser} ->
|
||||
# Send game start packet
|
||||
send_game_start(client_state, loser)
|
||||
|
||||
{:error, :not_ready} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_give_up(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
case MiniGame.give_up(game_id, character.id) do
|
||||
{:give_up, winner} ->
|
||||
# Send game result
|
||||
send_game_result(client_state, 0, winner)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_request_tie(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
MiniGame.request_tie(game_id, character.id)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_answer_tie(packet, client_state) do
|
||||
{accept, _packet} = In.decode_byte(packet)
|
||||
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
case MiniGame.answer_tie(game_id, character.id, accept > 0) do
|
||||
{:tie, _} ->
|
||||
send_game_result(client_state, 1, 0)
|
||||
|
||||
{:deny, _} ->
|
||||
send_deny_tie(client_state)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_skip(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
MiniGame.skip_turn(game_id, character.id)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_move_omok(packet, client_state) do
|
||||
{x, packet} = In.decode_int(packet)
|
||||
{y, packet} = In.decode_int(packet)
|
||||
{piece_type, _packet} = In.decode_byte(packet)
|
||||
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
case MiniGame.make_omok_move(game_id, character.id, x, y, piece_type) do
|
||||
{:ok, _won} ->
|
||||
# Broadcast move to all players
|
||||
:ok
|
||||
|
||||
{:win, winner} ->
|
||||
send_game_result(client_state, 2, winner)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("Omok move failed: #{reason}")
|
||||
end
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_select_card(packet, client_state) do
|
||||
{slot, _packet} = In.decode_byte(packet)
|
||||
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
case MiniGame.select_card(game_id, character.id, slot) do
|
||||
{:first_card, _} ->
|
||||
:ok
|
||||
|
||||
{:match, _} ->
|
||||
:ok
|
||||
|
||||
{:no_match, _} ->
|
||||
:ok
|
||||
|
||||
{:game_win, winner} ->
|
||||
send_game_result(client_state, 2, winner)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_exit_after_game(_packet, client_state) do
|
||||
with game_id <- client_state.player_shop,
|
||||
true <- game_id != nil,
|
||||
{:ok, character} <- get_character(client_state) do
|
||||
MiniGame.set_exit_after(game_id, character.id)
|
||||
end
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Chat Handler
|
||||
# ============================================================================
|
||||
|
||||
defp handle_chat(packet, client_state) do
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{message, _packet} = In.decode_string(packet)
|
||||
|
||||
with {:ok, character} <- get_character(client_state),
|
||||
shop_id <- client_state.player_shop,
|
||||
true <- shop_id != nil do
|
||||
# Broadcast to all visitors
|
||||
packet = encode_shop_chat(character.name, message)
|
||||
PlayerShop.broadcast_to_visitors(shop_id, packet, true)
|
||||
end
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Fredrick/Merch Store Handlers
|
||||
# ============================================================================
|
||||
|
||||
defp handle_display_merch(client_state) do
|
||||
# Check for stored items
|
||||
# For now, return empty
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_open_merch_store(client_state) do
|
||||
# Open the Fredrick item store dialog
|
||||
packet = encode_merch_item_store()
|
||||
send_packet(client_state, packet)
|
||||
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
defp handle_retrieve_items(_packet, client_state) do
|
||||
# Retrieve items from Fredrick
|
||||
# For now, just acknowledge
|
||||
send_enable_actions(client_state)
|
||||
{:ok, client_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Packet Encoders
|
||||
# ============================================================================
|
||||
|
||||
defp encode_player_shop(shop, is_owner) do
|
||||
# Player shop packet
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x05) # Shop type
|
||||
|> Out.encode_byte(PlayerShop.shop_type())
|
||||
|> Out.encode_int(shop.id)
|
||||
|> Out.encode_string(shop.owner_name)
|
||||
|> Out.encode_string(shop.description)
|
||||
|> Out.encode_byte(0) # Password flag
|
||||
|> Out.encode_byte(length(shop.items))
|
||||
|> encode_shop_items(shop.items)
|
||||
|> Out.encode_byte(if is_owner, do: 0, else: 1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_hired_merchant(merchant, is_owner) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x05)
|
||||
|> Out.encode_byte(HiredMerchant.shop_type())
|
||||
|> Out.encode_int(merchant.id)
|
||||
|> Out.encode_string(merchant.owner_name)
|
||||
|> Out.encode_string(merchant.description)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_int(0) # Time remaining
|
||||
|> Out.encode_byte(0) # Visitor count
|
||||
|> Out.encode_byte(0) # Has items
|
||||
|> Out.encode_byte(if is_owner, do: 0, else: 1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_mini_game(game) do
|
||||
game_type =
|
||||
case game.game_type do
|
||||
1 -> MiniGame.shop_type(%{game_type: 1})
|
||||
2 -> MiniGame.shop_type(%{game_type: 2})
|
||||
end
|
||||
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x05)
|
||||
|> Out.encode_byte(game_type)
|
||||
|> Out.encode_int(game.id)
|
||||
|> Out.encode_string(game.owner_name)
|
||||
|> Out.encode_string(game.description)
|
||||
|> Out.encode_byte(if game.password != "", do: 1, else: 0)
|
||||
|> Out.encode_byte(0) # Piece type
|
||||
|> Out.encode_byte(1) # Is owner
|
||||
|> Out.encode_byte(0) # Loser
|
||||
|> Out.encode_byte(0) # Turn
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_shop_items(packet, items) do
|
||||
Enum.reduce(items, packet, fn item, p ->
|
||||
p
|
||||
|> Out.encode_short(item.bundles)
|
||||
|> Out.encode_short(item.item.quantity)
|
||||
|> Out.encode_int(item.price)
|
||||
|> encode_item(item.item)
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_item(packet, %Item{} = item) do
|
||||
packet
|
||||
|> Out.encode_byte(2) # Item type
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_byte(0) # Has cash ID
|
||||
|> Out.encode_long(0) # Expiration
|
||||
|> Out.encode_short(item.quantity)
|
||||
|> Out.encode_string(item.owner)
|
||||
end
|
||||
|
||||
defp encode_item(packet, %Equip{} = equip) do
|
||||
packet
|
||||
|> Out.encode_byte(1) # Equip type
|
||||
|> Out.encode_int(equip.item_id)
|
||||
|> Out.encode_byte(0) # Has cash ID
|
||||
|> Out.encode_long(0) # Expiration
|
||||
# Equipment stats would go here
|
||||
|> Out.encode_bytes(<<0::size(100)-unit(8)>>)
|
||||
end
|
||||
|
||||
defp encode_shop_chat(name, message) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x06) # Chat
|
||||
|> Out.encode_byte(0) # Slot
|
||||
|> Out.encode_string("#{name} : #{message}")
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_shop_item_update(shop) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x07) # Update
|
||||
|> encode_shop_items(shop.items)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_visitor_view(visitors) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0A) # Visitor view
|
||||
|> Out.encode_byte(length(visitors))
|
||||
|> encode_visitor_list(visitors)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_visitor_list(packet, visitors) do
|
||||
Enum.reduce(visitors, packet, fn name, p ->
|
||||
p
|
||||
|> Out.encode_string(name)
|
||||
|> Out.encode_long(0) # Visit time
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_blacklist_view(blacklist) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0B) # Blacklist view
|
||||
|> Out.encode_byte(length(blacklist))
|
||||
|> encode_string_list(blacklist)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_string_list(packet, strings) do
|
||||
Enum.reduce(strings, packet, fn str, p ->
|
||||
Out.encode_string(p, str)
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_game_start(loser) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0C) # Start
|
||||
|> Out.encode_byte(loser)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_game_result(result_type, winner) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0D) # Result
|
||||
|> Out.encode_byte(result_type) # 0 = give up, 1 = tie, 2 = win
|
||||
|> Out.encode_byte(winner)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_deny_tie do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0E) # Deny tie
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_merch_item_store do
|
||||
Out.new(Opcodes.lp_merch_item_store())
|
||||
|> Out.encode_byte(0x24)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp send_shop_item_update(client_state, shop_id) do
|
||||
# Get shop state and send update
|
||||
case PlayerShop.get_state(shop_id) do
|
||||
{:error, _} ->
|
||||
case HiredMerchant.get_state(shop_id) do
|
||||
{:error, _} -> :ok
|
||||
state -> send_packet(client_state, encode_shop_item_update(state))
|
||||
end
|
||||
|
||||
state ->
|
||||
send_packet(client_state, encode_shop_item_update(state))
|
||||
end
|
||||
end
|
||||
|
||||
defp send_game_start(client_state, loser) do
|
||||
packet = encode_game_start(loser)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp send_game_result(client_state, result_type, winner) do
|
||||
packet = encode_game_result(result_type, winner)
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp send_deny_tie(client_state) do
|
||||
packet = encode_deny_tie()
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp send_shop_error_message(client_state, error, msg_type) do
|
||||
Out.new(Opcodes.lp_player_interaction())
|
||||
|> Out.encode_byte(0x0A) # Error
|
||||
|> Out.encode_byte(error)
|
||||
|> Out.encode_byte(msg_type)
|
||||
|> Out.to_data()
|
||||
|> then(&send_packet(client_state, &1))
|
||||
end
|
||||
|
||||
defp send_drop_message(client_state, msg_type, message) do
|
||||
Out.new(Opcodes.lp_blow_weather())
|
||||
|> Out.encode_int(msg_type)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
|> then(&send_packet(client_state, &1))
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
{:error, :no_character}
|
||||
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] ->
|
||||
case Odinsea.Game.Character.get_state(pid) do
|
||||
{:ok, state} -> {:ok, state}
|
||||
error -> error
|
||||
end
|
||||
|
||||
[] ->
|
||||
{:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(client_state, data) do
|
||||
if client_state.client_pid do
|
||||
send(client_state.client_pid, {:send_packet, data})
|
||||
end
|
||||
end
|
||||
|
||||
defp send_enable_actions(client_state) do
|
||||
packet = <<0x0D, 0x00, 0x00>>
|
||||
send_packet(client_state, packet)
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
674
lib/odinsea/channel/handler/players.ex
Normal file
674
lib/odinsea/channel/handler/players.ex
Normal file
@@ -0,0 +1,674 @@
|
||||
defmodule Odinsea.Channel.Handler.Players do
|
||||
@moduledoc """
|
||||
Handles general player operation packets.
|
||||
|
||||
Ported from: src/handling/channel/handler/PlayersHandler.java
|
||||
|
||||
## Main Handlers
|
||||
- handle_note/2 - Cash note system
|
||||
- handle_give_fame/2 - Fame system
|
||||
- handle_use_door/2 - Party door usage
|
||||
- handle_use_mech_door/2 - Mechanic door usage
|
||||
- handle_transform_player/2 - Transformation items
|
||||
- handle_hit_reactor/2 - Reactor hit
|
||||
- handle_touch_reactor/2 - Reactor touch
|
||||
- handle_hit_coconut/2 - Coconut event
|
||||
- handle_follow_request/2 - Follow request
|
||||
- handle_follow_reply/2 - Follow reply
|
||||
- handle_ring_action/2 - Marriage rings
|
||||
- handle_solomon/2 - Solomon's books
|
||||
- handle_gach_exp/2 - Gachapon EXP
|
||||
- handle_report/2 - Player reporting
|
||||
- handle_monster_book_info/2 - Monster book info
|
||||
- handle_change_set/2 - Card set change
|
||||
- handle_enter_pvp/2 - Enter PVP
|
||||
- handle_respawn_pvp/2 - PVP respawn
|
||||
- handle_leave_pvp/2 - Leave PVP
|
||||
- handle_attack_pvp/2 - PVP attack
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.{Character, Map}
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Note System
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles cash note operations (CP_NOTE_ACTION / 0xAD).
|
||||
|
||||
Types:
|
||||
- 0: Send note with item
|
||||
- 1: Delete notes
|
||||
|
||||
Reference: PlayersHandler.Note()
|
||||
"""
|
||||
def handle_note(packet, client_pid) do
|
||||
type = In.decode_byte(packet)
|
||||
|
||||
case type do
|
||||
0 ->
|
||||
# Send note
|
||||
name = In.decode_string(packet)
|
||||
msg = In.decode_string(packet)
|
||||
fame = In.decode_byte(packet) > 0
|
||||
_ = In.decode_int(packet) # unknown
|
||||
cash_id = In.decode_long(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Validate item exists in cash inventory
|
||||
# TODO: Send note to recipient
|
||||
Logger.debug("Send note to #{name}: #{msg}, fame: #{fame}, cash_id: #{cash_id}, character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to send note: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
1 ->
|
||||
# Delete notes
|
||||
num = In.decode_byte(packet)
|
||||
_ = In.decode_short(packet) # skip 2
|
||||
|
||||
notes_to_delete = Enum.map(1..num, fn _ ->
|
||||
id = In.decode_int(packet)
|
||||
fame_delete = In.decode_byte(packet) > 0
|
||||
{id, fame_delete}
|
||||
end)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Delete notes from database
|
||||
Logger.debug("Delete notes: #{inspect(notes_to_delete)}, character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to delete notes: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unhandled note action: #{type}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Fame System
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles giving fame (CP_GIVE_FAME / 0x73).
|
||||
|
||||
Reference: PlayersHandler.GiveFame()
|
||||
"""
|
||||
def handle_give_fame(packet, client_pid) do
|
||||
target_id = In.decode_int(packet)
|
||||
mode = In.decode_byte(packet) # 0 = down, 1 = up
|
||||
fame_change = if mode == 0, do: -1, else: 1
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate target exists on map
|
||||
# TODO: Check target is not self
|
||||
# TODO: Check character level >= 15
|
||||
# TODO: Check fame cooldown
|
||||
# TODO: Apply fame change
|
||||
# TODO: Send response packets
|
||||
|
||||
Logger.debug("Give fame: #{fame_change} to #{target_id}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to give fame: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Door Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles door usage (CP_USE_DOOR / 0xAF).
|
||||
|
||||
Mystic Door (Priest skill) - warp to town or back.
|
||||
|
||||
Reference: PlayersHandler.UseDoor()
|
||||
"""
|
||||
def handle_use_door(packet, client_pid) do
|
||||
oid = In.decode_int(packet)
|
||||
mode = In.decode_byte(packet) == 0 # 0 = target to town, 1 = town to target
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Find door by owner ID
|
||||
# TODO: Validate door is active
|
||||
# TODO: Warp character to appropriate destination
|
||||
|
||||
Logger.debug("Use door: OID #{oid}, mode #{mode}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use door: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles mechanic door usage (CP_USE_MECH_DOOR / 0xB0).
|
||||
|
||||
Mechanic teleport doors.
|
||||
|
||||
Reference: PlayersHandler.UseMechDoor()
|
||||
"""
|
||||
def handle_use_mech_door(packet, client_pid) do
|
||||
oid = In.decode_int(packet)
|
||||
pos_x = In.decode_short(packet)
|
||||
pos_y = In.decode_short(packet)
|
||||
mode = In.decode_byte(packet) # door ID
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# Send enable actions
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
|
||||
# TODO: Find mechanic door by owner ID and door ID
|
||||
# TODO: Move character to position
|
||||
|
||||
Logger.debug("Use mech door: OID #{oid}, pos (#{pos_x}, #{pos_y}), mode #{mode}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use mech door: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Transformation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles player transformation (CP_TRANSFORM_PLAYER / 0xD2).
|
||||
|
||||
Item-based transformations (e.g., 2212000 - prank item).
|
||||
|
||||
Reference: PlayersHandler.TransformPlayer()
|
||||
"""
|
||||
def handle_transform_player(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
target_name = In.decode_string(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate item exists in inventory
|
||||
# TODO: Find target by name
|
||||
# TODO: Apply transformation effect
|
||||
# TODO: Consume item
|
||||
|
||||
Logger.debug("Transform player: item #{item_id}, slot #{slot}, target #{target_name}, tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to transform player: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles reactor hit (CP_DAMAGE_REACTOR / 0x10F).
|
||||
|
||||
Reference: PlayersHandler.HitReactor()
|
||||
"""
|
||||
def handle_hit_reactor(packet, client_pid) do
|
||||
oid = In.decode_int(packet)
|
||||
char_pos = In.decode_int(packet)
|
||||
stance = In.decode_short(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get reactor from map
|
||||
# TODO: Validate reactor is alive
|
||||
# TODO: Hit reactor with damage
|
||||
|
||||
Logger.debug("Hit reactor: OID #{oid}, char_pos #{char_pos}, stance #{stance}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to hit reactor: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles reactor touch (CP_TOUCH_REACTOR / 0x110).
|
||||
|
||||
Reference: PlayersHandler.TouchReactor()
|
||||
"""
|
||||
def handle_touch_reactor(packet, client_pid) do
|
||||
oid = In.decode_int(packet)
|
||||
touched = if byte_size(packet.data) == 0, do: true, else: In.decode_byte(packet) > 0
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get reactor from map
|
||||
# TODO: Handle touch based on reactor type
|
||||
# TODO: Check required items for certain reactors
|
||||
|
||||
Logger.debug("Touch reactor: OID #{oid}, touched #{touched}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to touch reactor: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Event Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles coconut hit (CP_COCONUT / 0x11B).
|
||||
|
||||
Coconut event / Coke Play event.
|
||||
|
||||
Reference: PlayersHandler.hitCoconut()
|
||||
"""
|
||||
def handle_hit_coconut(packet, client_pid) do
|
||||
coconut_id = In.decode_short(packet)
|
||||
# Unknown bytes follow
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get coconut event for channel
|
||||
# TODO: Validate coconut can be hit
|
||||
# TODO: Process hit (falling, bomb, points)
|
||||
|
||||
Logger.debug("Hit coconut: ID #{coconut_id}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to hit coconut: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Follow System
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles follow request (CP_FOLLOW_REQUEST / 0x8E).
|
||||
|
||||
Reference: PlayersHandler.FollowRequest()
|
||||
"""
|
||||
def handle_follow_request(packet, client_pid) do
|
||||
target_id = In.decode_int(packet)
|
||||
follow_mode = In.decode_byte(packet) > 0
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Find target on map
|
||||
# TODO: Check distance
|
||||
# TODO: Send follow request
|
||||
|
||||
Logger.debug("Follow request: target #{target_id}, mode #{follow_mode}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle follow request: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles follow reply (CP_FOLLOW_REPLY / 0x91).
|
||||
|
||||
Reference: PlayersHandler.FollowReply()
|
||||
"""
|
||||
def handle_follow_reply(packet, client_pid) do
|
||||
target_id = In.decode_int(packet)
|
||||
accepted = In.decode_byte(packet) > 0
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate follow request exists
|
||||
# TODO: Set follow state for both players
|
||||
# TODO: Broadcast follow effect
|
||||
|
||||
Logger.debug("Follow reply: target #{target_id}, accepted #{accepted}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle follow reply: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Marriage System
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles ring/marriage actions (CP_RING_ACTION / 0xB5).
|
||||
|
||||
Modes:
|
||||
- 0: Propose (DoRing)
|
||||
- 1: Cancel proposal
|
||||
- 2: Accept/Deny proposal
|
||||
- 3: Drop ring (ETC only)
|
||||
|
||||
Reference: PlayersHandler.RingAction(), PlayersHandler.DoRing()
|
||||
"""
|
||||
def handle_ring_action(packet, client_pid) do
|
||||
mode = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
0 ->
|
||||
# Propose
|
||||
name = In.decode_string(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Validate character is not married
|
||||
# TODO: Validate target exists
|
||||
# TODO: Validate has ring box item
|
||||
# TODO: Send proposal
|
||||
Logger.debug("Marriage proposal to #{name} with item #{item_id}, character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to propose: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
1 ->
|
||||
# Cancel proposal
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Cancel pending proposal
|
||||
Logger.debug("Cancel marriage proposal, character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to cancel proposal: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
2 ->
|
||||
# Accept/Deny
|
||||
accepted = In.decode_byte(packet) > 0
|
||||
name = In.decode_string(packet)
|
||||
id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Validate proposal exists
|
||||
# TODO: If accepted, create rings for both
|
||||
# TODO: Update marriage IDs
|
||||
Logger.debug("Marriage reply: #{accepted} to #{name} (#{id}), character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to reply to proposal: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
3 ->
|
||||
# Drop ring
|
||||
item_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, _char_state} ->
|
||||
# TODO: Validate ring is ETC type
|
||||
# TODO: Drop ring from inventory
|
||||
Logger.debug("Drop ring #{item_id}, character #{character_id}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to drop ring: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unhandled ring action mode: #{mode}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Solomon/Gachapon Systems
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles Solomon's books (CP_SOLOMON / 0x8C).
|
||||
|
||||
EXP books for level 50 and below.
|
||||
|
||||
Reference: PlayersHandler.Solomon()
|
||||
"""
|
||||
def handle_solomon(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate character level <= 50
|
||||
# TODO: Validate has gach exp available
|
||||
# TODO: Get EXP from item
|
||||
# TODO: Add gach EXP
|
||||
# TODO: Remove item
|
||||
|
||||
# Send enable actions
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
|
||||
Logger.debug("Solomon: item #{item_id}, slot #{slot}, tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to use Solomon book: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles Gachapon EXP claim (CP_GACH_EXP / 0x8D).
|
||||
|
||||
Reference: PlayersHandler.GachExp()
|
||||
"""
|
||||
def handle_gach_exp(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Check gach EXP > 0
|
||||
# TODO: Gain EXP with quest rate
|
||||
# TODO: Reset gach EXP
|
||||
|
||||
# Send enable actions
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
|
||||
Logger.debug("Gach EXP claim: tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to claim gach EXP: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reporting
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles player report (CP_REPORT / 0x94).
|
||||
|
||||
Report types: BOT, HACK, AD, HARASS, etc.
|
||||
|
||||
Reference: PlayersHandler.Report()
|
||||
"""
|
||||
def handle_report(packet, client_pid) do
|
||||
# Format varies by server type (GMS/non-GMS)
|
||||
report_type = In.decode_byte(packet)
|
||||
target_name = In.decode_string(packet)
|
||||
# Additional data may follow
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate target exists
|
||||
# TODO: Check report cooldown (2 hours)
|
||||
# TODO: Log report
|
||||
# TODO: Send to Discord if configured
|
||||
|
||||
Logger.debug("Report: type #{report_type}, target #{target_name}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle report: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Book
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles monster book info request (CP_GET_BOOK_INFO / 0x7FFA).
|
||||
|
||||
Reference: PlayersHandler.MonsterBookInfoRequest()
|
||||
"""
|
||||
def handle_monster_book_info(packet, client_pid) do
|
||||
_ = In.decode_int(packet) # unknown
|
||||
target_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Find target player
|
||||
# TODO: Get monster book info
|
||||
# TODO: Send info packet
|
||||
|
||||
# Send enable actions
|
||||
send(client_pid, {:send_packet, Packets.enable_actions()})
|
||||
|
||||
Logger.debug("Monster book info request: target #{target_id}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to get monster book info: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles card set change (CP_CHANGE_SET / 0x7FFE).
|
||||
|
||||
Reference: PlayersHandler.ChangeSet()
|
||||
"""
|
||||
def handle_change_set(packet, client_pid) do
|
||||
set_id = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate set exists
|
||||
# TODO: Change active card set
|
||||
# TODO: Apply book effects
|
||||
|
||||
Logger.debug("Change card set: #{set_id}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to change card set: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# PVP Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles enter PVP (CP_ENTER_PVP / 0x26).
|
||||
|
||||
Reference: PlayersHandler.EnterPVP()
|
||||
"""
|
||||
def handle_enter_pvp(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
_ = In.decode_byte(packet) # skip
|
||||
type = In.decode_byte(packet)
|
||||
level = In.decode_byte(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate not in party
|
||||
# TODO: Validate level range
|
||||
# TODO: Get PVP event manager
|
||||
# TODO: Register player for PVP
|
||||
|
||||
Logger.debug("Enter PVP: type #{type}, level #{level}, tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to enter PVP: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles PVP respawn (CP_PVP_RESPAWN / 0x9D).
|
||||
|
||||
Reference: PlayersHandler.RespawnPVP()
|
||||
"""
|
||||
def handle_respawn_pvp(packet, client_pid) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Check player is dead and in PVP
|
||||
# TODO: Heal player
|
||||
# TODO: Clear cooldowns
|
||||
# TODO: Warp to spawn point
|
||||
# TODO: Send score packet
|
||||
|
||||
Logger.debug("PVP respawn: character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to respawn in PVP: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles leave PVP (CP_LEAVE_PVP / 0x29).
|
||||
|
||||
Reference: PlayersHandler.LeavePVP()
|
||||
"""
|
||||
def handle_leave_pvp(packet, client_pid) do
|
||||
tick = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Calculate battle points/EXP
|
||||
# TODO: Clear buffs
|
||||
# TODO: Warp to lobby (960000000)
|
||||
# TODO: Update stats
|
||||
|
||||
Logger.debug("Leave PVP: tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to leave PVP: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles PVP attack (CP_PVP_ATTACK / 0x35).
|
||||
|
||||
Reference: PlayersHandler.AttackPVP()
|
||||
"""
|
||||
def handle_attack_pvp(packet, client_pid) do
|
||||
skill_id = In.decode_int(packet)
|
||||
# Complex packet structure for attack data
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate in PVP and alive
|
||||
# TODO: Parse attack data
|
||||
# TODO: Calculate damage
|
||||
# TODO: Apply damage to targets
|
||||
# TODO: Update score
|
||||
# TODO: Broadcast attack
|
||||
|
||||
Logger.debug("PVP attack: skill #{skill_id}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle PVP attack: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
263
lib/odinsea/channel/handler/summon.ex
Normal file
263
lib/odinsea/channel/handler/summon.ex
Normal file
@@ -0,0 +1,263 @@
|
||||
defmodule Odinsea.Channel.Handler.Summon do
|
||||
@moduledoc """
|
||||
Handles summon-related packets (puppet, dragon, summons).
|
||||
|
||||
Ported from: src/handling/channel/handler/SummonHandler.java
|
||||
|
||||
## Main Handlers
|
||||
- handle_move_dragon/2 - Dragon movement
|
||||
- handle_move_summon/2 - Summon movement
|
||||
- handle_damage_summon/2 - Summon taking damage
|
||||
- handle_summon_attack/2 - Summon attacking
|
||||
- handle_remove_summon/2 - Remove summon
|
||||
- handle_sub_summon/2 - Summon sub-skill (healing, etc.)
|
||||
- handle_pvp_summon/2 - PVP summon attack
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.{Character, Map}
|
||||
|
||||
# ============================================================================
|
||||
# Dragon Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles dragon movement (CP_MOVE_DRAGON / 0xE7).
|
||||
|
||||
Reference: SummonHandler.MoveDragon()
|
||||
"""
|
||||
def handle_move_dragon(packet, client_pid) do
|
||||
# Skip 8 bytes (position data)
|
||||
_ = In.decode_long(packet)
|
||||
|
||||
# Parse movement data
|
||||
# TODO: Implement full movement parsing
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate dragon exists for character
|
||||
# TODO: Update dragon position
|
||||
# TODO: Broadcast movement to other players
|
||||
|
||||
Logger.debug("Dragon move: character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle dragon move: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Summon Handlers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles summon movement (CP_MOVE_SUMMON / 0xDF).
|
||||
|
||||
Reference: SummonHandler.MoveSummon()
|
||||
"""
|
||||
def handle_move_summon(packet, client_pid) do
|
||||
summon_oid = In.decode_int(packet)
|
||||
|
||||
# Skip 8 bytes (start position)
|
||||
_ = In.decode_long(packet)
|
||||
|
||||
# Parse movement data
|
||||
# TODO: Implement movement parsing
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate summon exists and belongs to character
|
||||
# TODO: Check summon movement type (skip if STATIONARY)
|
||||
# TODO: Update summon position
|
||||
# TODO: Broadcast movement to other players
|
||||
|
||||
Logger.debug("Summon move: OID #{summon_oid}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle summon move: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles summon taking damage (CP_DAMAGE_SUMMON / 0xE1).
|
||||
|
||||
Puppet summons can take damage and be destroyed.
|
||||
|
||||
Reference: SummonHandler.DamageSummon()
|
||||
"""
|
||||
def handle_damage_summon(packet, client_pid) do
|
||||
unk_byte = In.decode_byte(packet)
|
||||
damage = In.decode_int(packet)
|
||||
monster_id_from = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Find puppet summon for character
|
||||
# TODO: Apply damage to summon HP
|
||||
# TODO: Broadcast damage packet
|
||||
# TODO: Remove summon if HP <= 0
|
||||
|
||||
Logger.debug("Summon damage: #{damage} from mob #{monster_id_from}, unk #{unk_byte}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle summon damage: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles summon attack (CP_SUMMON_ATTACK / 0xE0).
|
||||
|
||||
Summons attack monsters with their skills.
|
||||
|
||||
Reference: SummonHandler.SummonAttack()
|
||||
"""
|
||||
def handle_summon_attack(packet, client_pid) do
|
||||
summon_oid = In.decode_int(packet)
|
||||
|
||||
# Skip bytes based on game version
|
||||
_ = In.decode_long(packet) # tick or unknown
|
||||
|
||||
tick = In.decode_int(packet)
|
||||
_ = In.decode_long(packet) # skip
|
||||
|
||||
animation = In.decode_byte(packet)
|
||||
_ = In.decode_long(packet) # CRC32 skip
|
||||
|
||||
mob_count = In.decode_byte(packet)
|
||||
|
||||
# Parse attack targets
|
||||
targets = parse_summon_targets(packet, mob_count)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate summon exists and belongs to character
|
||||
# TODO: Check attack frequency (anti-cheat)
|
||||
# TODO: Calculate damage for each target
|
||||
# TODO: Apply damage to monsters
|
||||
# TODO: Broadcast attack packet
|
||||
# TODO: Remove summon if not multi-attack
|
||||
|
||||
Logger.debug("Summon attack: OID #{summon_oid}, tick #{tick}, anim #{animation}, targets #{length(targets)}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle summon attack: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles summon removal (CP_REMOVE_SUMMON / 0xE3).
|
||||
|
||||
Player manually removes their summon.
|
||||
|
||||
Reference: SummonHandler.RemoveSummon()
|
||||
"""
|
||||
def handle_remove_summon(packet, client_pid) do
|
||||
summon_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate summon exists and belongs to character
|
||||
# TODO: Check if summon can be removed (not rock/shock)
|
||||
# TODO: Remove summon from map
|
||||
# TODO: Broadcast removal packet
|
||||
# TODO: Cancel summon buff
|
||||
|
||||
Logger.debug("Remove summon: OID #{summon_oid}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle remove summon: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles summon sub-skill (CP_SUB_SUMMON / 0xE2).
|
||||
|
||||
Special summon abilities like:
|
||||
- 35121009: Mech summon extension (spawn additional summons)
|
||||
- 35111011: Healing
|
||||
- 1321007: Beholder (heal/buff)
|
||||
|
||||
Reference: SummonHandler.SubSummon()
|
||||
"""
|
||||
def handle_sub_summon(packet, client_pid) do
|
||||
summon_oid = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Get summon by OID
|
||||
# TODO: Check summon cooldown
|
||||
# TODO: Execute sub-skill based on summon skill ID
|
||||
# TODO: Apply effects (heal, spawn, buff)
|
||||
# TODO: Broadcast skill effect
|
||||
|
||||
Logger.debug("Sub summon: OID #{summon_oid}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle sub summon: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles PVP summon attack (CP_PVP_SUMMON / 0xE4).
|
||||
|
||||
Summon attacks in PVP mode.
|
||||
|
||||
Reference: SummonHandler.SummonPVP()
|
||||
"""
|
||||
def handle_pvp_summon(packet, client_pid) do
|
||||
summon_oid = In.decode_int(packet)
|
||||
|
||||
# Parse attack data based on packet length
|
||||
tick = if byte_size(packet.data) >= 27 do
|
||||
packet
|
||||
|> skip_bytes(23)
|
||||
|> In.decode_int()
|
||||
else
|
||||
-1
|
||||
end
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate player is in PVP
|
||||
# TODO: Validate summon belongs to character
|
||||
# TODO: Calculate PVP damage
|
||||
# TODO: Apply damage to targets
|
||||
# TODO: Update PVP score
|
||||
# TODO: Broadcast attack packet
|
||||
|
||||
Logger.debug("PVP summon attack: OID #{summon_oid}, tick #{tick}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle PVP summon: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp parse_summon_targets(packet, count) do
|
||||
Enum.reduce(1..count, [], fn _, acc ->
|
||||
mob_oid = In.decode_int(packet)
|
||||
_ = In.decode_bytes(packet, 18) # skip unknown
|
||||
damage = In.decode_int(packet)
|
||||
|
||||
[{mob_oid, damage} | acc]
|
||||
end)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp skip_bytes(packet, count) do
|
||||
In.decode_bytes(packet, count)
|
||||
packet
|
||||
end
|
||||
end
|
||||
158
lib/odinsea/channel/handler/ui.ex
Normal file
158
lib/odinsea/channel/handler/ui.ex
Normal file
@@ -0,0 +1,158 @@
|
||||
defmodule Odinsea.Channel.Handler.UI do
|
||||
@moduledoc """
|
||||
Handles user interface interaction packets.
|
||||
|
||||
Ported from: src/handling/channel/handler/UserInterfaceHandler.java
|
||||
|
||||
## Main Handlers
|
||||
- handle_cygnus_summon/2 - Cygnus/Aran first job advancement
|
||||
- handle_game_poll/2 - In-game poll
|
||||
- handle_ship_object/2 - Ship/boat object requests
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.{Character, Map}
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
# ============================================================================
|
||||
# Job Advancement
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles Cygnus/Aran summon NPC request (CP_CYGNUS_SUMMON / 0xC5).
|
||||
|
||||
Opens the first job advancement NPC for Cygnus and Aran characters.
|
||||
- Job 2000 (Aran) → NPC 1202000
|
||||
- Job 1000 (Cygnus Knight) → NPC 1101008
|
||||
|
||||
Reference: UserInterfaceHandler.CygnusSummon_NPCRequest()
|
||||
"""
|
||||
def handle_cygnus_summon(_packet, client_pid) do
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
npc_id = case char_state.job do
|
||||
2000 -> 1202000 # Aran
|
||||
1000 -> 1101008 # Cygnus Knight
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
if npc_id do
|
||||
# TODO: Start NPC script
|
||||
# NPCScriptManager.getInstance().start(c, npc_id)
|
||||
Logger.debug("Cygnus/Aran summon NPC: #{npc_id} for character #{character_id}")
|
||||
else
|
||||
Logger.debug("Invalid job for Cygnus summon: #{char_state.job}, character #{character_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle Cygnus summon: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Game Poll
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles in-game poll (CP_GAME_POLL / 0xD4).
|
||||
|
||||
Player submits response to server poll/questionnaire.
|
||||
|
||||
Reference: UserInterfaceHandler.InGame_Poll()
|
||||
"""
|
||||
def handle_game_poll(packet, client_pid) do
|
||||
# tick = In.decode_int(packet)
|
||||
selection = In.decode_int(packet)
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
# TODO: Validate poll is enabled
|
||||
# TODO: Validate selection is valid
|
||||
# TODO: Record poll response
|
||||
# TODO: Send reply packet
|
||||
|
||||
Logger.debug("Game poll response: #{selection}, character #{character_id}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle game poll: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Ship/Boat Objects
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Handles ship object request (CP_SHIP_OBJECT / 0x127).
|
||||
|
||||
Client requests ship/boat status for station maps.
|
||||
Used for boats between continents (Ellinia-Orbis, etc.)
|
||||
|
||||
Packet format varies by map:
|
||||
- BB 00 6C 24 05 06 00 - Ellinia
|
||||
- BB 00 6E 1C 4E 0E 00 - Leafre
|
||||
|
||||
Reference: UserInterfaceHandler.ShipObjectRequest()
|
||||
"""
|
||||
def handle_ship_object(packet, client_pid) do
|
||||
# Map ID is encoded in the packet in various ways
|
||||
# The full packet structure varies by client version
|
||||
|
||||
case Character.get_state_by_client(client_pid) do
|
||||
{:ok, character_id, char_state} ->
|
||||
map_id = char_state.map
|
||||
|
||||
# Determine ship effect based on map
|
||||
effect = get_ship_effect(map_id)
|
||||
|
||||
# TODO: Check event manager for actual docked status
|
||||
# Boats/Trains/Geenie/Flight managers
|
||||
|
||||
Logger.debug("Ship object request: map #{map_id}, effect #{effect}, character #{character_id}")
|
||||
|
||||
# TODO: Send boat packet with effect
|
||||
# c.sendPacket(MaplePacketCreator.boatPacket(effect))
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to handle ship object: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
# Returns the ship effect value for a given map
|
||||
# Effect: 1 = Coming, 3 = Going, 1034 = Balrog
|
||||
defp get_ship_effect(map_id) do
|
||||
case map_id do
|
||||
# Ellinia Station >> Orbis
|
||||
101000300 -> 3
|
||||
# Orbis Station >> Ellinia
|
||||
200000111 -> 3
|
||||
# Orbis Station >> Ludi
|
||||
200000121 -> 3
|
||||
# Ludi Station >> Orbis
|
||||
220000110 -> 3
|
||||
# Orbis Station >> Ariant
|
||||
200000151 -> 3
|
||||
# Ariant Station >> Orbis
|
||||
260000100 -> 3
|
||||
# Leafre Station >> Orbis
|
||||
240000110 -> 3
|
||||
# Orbis Station >> Leafre
|
||||
200000131 -> 3
|
||||
# During boat rides
|
||||
200090010 -> 1 # To Orbis
|
||||
200090000 -> 1 # To Ellinia
|
||||
_ -> 3 # Default: going
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ defmodule Odinsea.Channel.Packets do
|
||||
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Game.Reactor
|
||||
|
||||
@doc """
|
||||
Sends character information on login.
|
||||
@@ -298,4 +299,915 @@ defmodule Odinsea.Channel.Packets do
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Spawns a monster on the map (LP_MobEnterField).
|
||||
|
||||
## Parameters
|
||||
- monster: Monster.t() struct
|
||||
- spawn_type: spawn animation type (-1 = normal, -2 = regen, -3 = revive, etc.)
|
||||
- link: linked mob OID (for multi-mobs)
|
||||
|
||||
Reference: MobPacket.spawnMonster()
|
||||
"""
|
||||
def spawn_monster(monster, spawn_type \\ -1, link \\ 0) do
|
||||
Out.new(Opcodes.lp_spawn_monster())
|
||||
|> Out.encode_int(monster.oid)
|
||||
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|
||||
|> Out.encode_int(monster.mob_id)
|
||||
# Temporary stat encoding (buffs/debuffs)
|
||||
|> encode_mob_temporary_stat(monster)
|
||||
# Position
|
||||
|> Out.encode_short(monster.position.x)
|
||||
|> Out.encode_short(monster.position.y)
|
||||
# Move action (bitfield)
|
||||
|> Out.encode_byte(monster.stance)
|
||||
# Foothold SN
|
||||
|> Out.encode_short(monster.fh)
|
||||
# Origin FH
|
||||
|> Out.encode_short(monster.fh)
|
||||
# Spawn type
|
||||
|> Out.encode_byte(spawn_type)
|
||||
# Link OID (for spawn_type -3 or >= 0)
|
||||
|> then(fn packet ->
|
||||
if spawn_type == -3 or spawn_type >= 0 do
|
||||
Out.encode_int(packet, link)
|
||||
else
|
||||
packet
|
||||
end
|
||||
end)
|
||||
# Carnival team
|
||||
|> Out.encode_byte(0)
|
||||
# Aftershock - 8 bytes (0xFF at end for GMS)
|
||||
|> Out.encode_long(0)
|
||||
# GMS specific
|
||||
|> Out.encode_byte(-1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Assigns monster control to a player (LP_MobChangeController).
|
||||
|
||||
## Parameters
|
||||
- monster: Monster.t() struct
|
||||
- new_spawn: whether this is a new spawn
|
||||
- aggro: aggro mode (2 = aggro, 1 = normal)
|
||||
|
||||
Reference: MobPacket.controlMonster()
|
||||
"""
|
||||
def control_monster(monster, new_spawn \\ false, aggro \\ false) do
|
||||
Out.new(Opcodes.lp_spawn_monster_control())
|
||||
|> Out.encode_byte(if aggro, do: 2, else: 1)
|
||||
|> Out.encode_int(monster.oid)
|
||||
|> Out.encode_byte(1) # 1 = Control normal, 5 = Control none
|
||||
|> Out.encode_int(monster.mob_id)
|
||||
# Temporary stat encoding
|
||||
|> encode_mob_temporary_stat(monster)
|
||||
# Position
|
||||
|> Out.encode_short(monster.position.x)
|
||||
|> Out.encode_short(monster.position.y)
|
||||
# Move action
|
||||
|> Out.encode_byte(monster.stance)
|
||||
# Foothold SN
|
||||
|> Out.encode_short(monster.fh)
|
||||
# Origin FH
|
||||
|> Out.encode_short(monster.fh)
|
||||
# Spawn type (-4 = fake, -2 = new spawn, -1 = normal)
|
||||
|> Out.encode_byte(cond do
|
||||
new_spawn -> -2
|
||||
true -> -1
|
||||
end)
|
||||
# Carnival team
|
||||
|> Out.encode_byte(0)
|
||||
# Big bang - another long
|
||||
|> Out.encode_long(0)
|
||||
# GMS specific
|
||||
|> Out.encode_byte(-1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops controlling a monster (LP_MobChangeController with byte 0).
|
||||
|
||||
Reference: MobPacket.stopControllingMonster()
|
||||
"""
|
||||
def stop_controlling_monster(oid) do
|
||||
Out.new(Opcodes.lp_spawn_monster_control())
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Monster movement packet (LP_MobMove).
|
||||
|
||||
## Parameters
|
||||
- oid: monster object ID
|
||||
- next_attack_possible: whether next attack is possible
|
||||
- left: facing direction (raw byte from client)
|
||||
- skill_data: skill data from movement
|
||||
- move_path: movement path data (binary from client)
|
||||
|
||||
Reference: MobPacket.onMove()
|
||||
"""
|
||||
def move_monster(oid, next_attack_possible, left, skill_data, move_path) do
|
||||
Out.new(Opcodes.lp_move_monster())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|
||||
|> Out.encode_byte(left)
|
||||
|> Out.encode_int(skill_data)
|
||||
|> Out.encode_int(0) # multi target for ball size
|
||||
|> Out.encode_int(0) # rand time for area attack
|
||||
# Movement path (raw binary from client)
|
||||
|> Out.encode_bytes(move_path)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Damage monster packet (LP_MobDamaged).
|
||||
|
||||
## Parameters
|
||||
- oid: monster object ID
|
||||
- damage: damage amount (capped at Integer max)
|
||||
|
||||
Reference: MobPacket.damageMonster()
|
||||
"""
|
||||
def damage_monster(oid, damage) do
|
||||
# Cap damage at max int
|
||||
damage_capped = min(damage, 2_147_483_647)
|
||||
|
||||
Out.new(Opcodes.lp_damage_monster())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_int(damage_capped)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kill monster packet (LP_MobLeaveField).
|
||||
|
||||
## Parameters
|
||||
- monster: Monster.t() struct
|
||||
- leave_type: how the mob is leaving (0 = remain hp, 1 = etc, 2 = self destruct, etc.)
|
||||
|
||||
Reference: MobPacket.killMonster()
|
||||
"""
|
||||
def kill_monster(monster, leave_type \\ 1) do
|
||||
Out.new(Opcodes.lp_kill_monster())
|
||||
|> Out.encode_int(monster.oid)
|
||||
|> Out.encode_byte(leave_type)
|
||||
# If swallow type, encode swallower character ID
|
||||
|> then(fn packet ->
|
||||
if leave_type == 4 do
|
||||
Out.encode_int(packet, -1)
|
||||
else
|
||||
packet
|
||||
end
|
||||
end)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Show monster HP indicator (LP_MobHPIndicator).
|
||||
|
||||
## Parameters
|
||||
- oid: monster object ID
|
||||
- hp_percentage: HP percentage (0-100)
|
||||
|
||||
Reference: MobPacket.showMonsterHP()
|
||||
"""
|
||||
def show_monster_hp(oid, hp_percentage) do
|
||||
Out.new(Opcodes.lp_show_monster_hp())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.encode_byte(hp_percentage)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Show boss HP bar (BOSS_ENV).
|
||||
|
||||
## Parameters
|
||||
- monster: Monster.t() struct
|
||||
|
||||
Reference: MobPacket.showBossHP()
|
||||
"""
|
||||
def show_boss_hp(monster) do
|
||||
# Cap HP at max int for display
|
||||
current_hp = min(monster.hp, 2_147_483_647)
|
||||
max_hp = min(monster.max_hp, 2_147_483_647)
|
||||
|
||||
Out.new(0x9D) # BOSS_ENV opcode
|
||||
|> Out.encode_byte(5)
|
||||
|> Out.encode_int(monster.mob_id)
|
||||
|> Out.encode_int(current_hp)
|
||||
|> Out.encode_int(max_hp)
|
||||
|> Out.encode_byte(6) # Tag color (default)
|
||||
|> Out.encode_byte(5) # Tag bg color (default)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Monster control acknowledgment (LP_MobCtrlAck).
|
||||
|
||||
## Parameters
|
||||
- oid: monster object ID
|
||||
- mob_ctrl_sn: control sequence number
|
||||
- next_attack_possible: whether next attack is possible
|
||||
- mp: monster MP
|
||||
- skill_command: skill command from client
|
||||
- slv: skill level
|
||||
|
||||
Reference: MobPacket.onCtrlAck()
|
||||
"""
|
||||
def mob_ctrl_ack(oid, mob_ctrl_sn, next_attack_possible, mp, skill_command, slv) do
|
||||
Out.new(Opcodes.lp_move_monster_response())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.encode_short(mob_ctrl_sn)
|
||||
|> Out.encode_byte(if next_attack_possible, do: 1, else: 0)
|
||||
|> Out.encode_short(mp)
|
||||
|> Out.encode_byte(skill_command)
|
||||
|> Out.encode_byte(slv)
|
||||
|> Out.encode_int(0) # forced attack idx
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Spawns a reactor on the map (LP_ReactorEnterField).
|
||||
|
||||
## Parameters
|
||||
- reactor: Reactor.t() struct
|
||||
|
||||
Reference: MaplePacketCreator.spawnReactor()
|
||||
"""
|
||||
def spawn_reactor(reactor) do
|
||||
Out.new(Opcodes.lp_reactor_spawn())
|
||||
|> Out.encode_int(reactor.oid)
|
||||
|> Out.encode_int(reactor.reactor_id)
|
||||
|> Out.encode_byte(reactor.state)
|
||||
|> Out.encode_short(reactor.x)
|
||||
|> Out.encode_short(reactor.y)
|
||||
|> Out.encode_byte(reactor.facing_direction)
|
||||
|> Out.encode_string(reactor.name)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Triggers/hits a reactor (LP_ReactorChangeState).
|
||||
|
||||
## Parameters
|
||||
- reactor: Reactor.t() struct
|
||||
- stance: stance value (usually 0)
|
||||
|
||||
Reference: MaplePacketCreator.triggerReactor()
|
||||
"""
|
||||
def trigger_reactor(reactor, stance \\ 0) do
|
||||
# Cap state for herb/vein reactors (100000-200011 range)
|
||||
state =
|
||||
if reactor.reactor_id >= 100000 and reactor.reactor_id <= 200011 do
|
||||
min(reactor.state, 4)
|
||||
else
|
||||
reactor.state
|
||||
end
|
||||
|
||||
Out.new(Opcodes.lp_reactor_hit())
|
||||
|> Out.encode_int(reactor.oid)
|
||||
|> Out.encode_byte(state)
|
||||
|> Out.encode_short(reactor.x)
|
||||
|> Out.encode_short(reactor.y)
|
||||
|> Out.encode_short(stance)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Destroys/removes a reactor from the map (LP_ReactorLeaveField).
|
||||
|
||||
## Parameters
|
||||
- reactor: Reactor.t() struct
|
||||
|
||||
Reference: MaplePacketCreator.destroyReactor()
|
||||
"""
|
||||
def destroy_reactor(reactor) do
|
||||
Out.new(Opcodes.lp_reactor_destroy())
|
||||
|> Out.encode_int(reactor.oid)
|
||||
|> Out.encode_byte(reactor.state)
|
||||
|> Out.encode_short(reactor.x)
|
||||
|> Out.encode_short(reactor.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions for Monster Encoding
|
||||
# ============================================================================
|
||||
|
||||
@doc false
|
||||
defp encode_mob_temporary_stat(packet, monster) do
|
||||
# For GMS v342, encode changed stats first
|
||||
packet
|
||||
|> encode_mob_changed_stats(monster)
|
||||
# Then encode temporary status effects (buffs/debuffs)
|
||||
|> encode_mob_status_mask(monster)
|
||||
end
|
||||
|
||||
@doc false
|
||||
defp encode_mob_changed_stats(packet, _monster) do
|
||||
# For GMS: encode byte 1 if stats are changed, 0 if not
|
||||
# For now, assume no changed stats
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
# If changed stats exist, encode:
|
||||
# - hp (int)
|
||||
# - mp (int)
|
||||
# - exp (int)
|
||||
# - watk (int)
|
||||
# - matk (int)
|
||||
# - PDRate (int)
|
||||
# - MDRate (int)
|
||||
# - acc (int)
|
||||
# - eva (int)
|
||||
# - pushed (int)
|
||||
# - level (int)
|
||||
end
|
||||
|
||||
@doc false
|
||||
defp encode_mob_status_mask(packet, monster) do
|
||||
# Encode status effects (poison, stun, freeze, etc.)
|
||||
# For now, assume no status effects - send empty mask
|
||||
# Mask is typically 16 integers (64 bytes) for GMS
|
||||
packet
|
||||
|> Out.encode_bytes(<<0::size(16 * 32)-little>>)
|
||||
# If status effects exist, encode for each effect:
|
||||
# - nOption (short)
|
||||
# - rOption (int) - skill ID | skill level << 16
|
||||
# - tOption (short) - duration / 500
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Admin/System Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Drop message packet (system message displayed to player).
|
||||
|
||||
Types:
|
||||
- 0 = Notice (blue)
|
||||
- 1 = Popup (red)
|
||||
- 2 = Megaphone
|
||||
- 3 = Super Megaphone
|
||||
- 4 = Top scrolling message
|
||||
- 5 = System message (yellow)
|
||||
- 6 = Quiz
|
||||
|
||||
Reference: MaplePacketCreator.dropMessage()
|
||||
"""
|
||||
def drop_message(type, message) do
|
||||
Out.new(Opcodes.lp_blow_weather())
|
||||
|> Out.encode_int(type)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Server-wide broadcast message.
|
||||
|
||||
Reference: MaplePacketCreator.serverMessage()
|
||||
"""
|
||||
def server_message(message) do
|
||||
Out.new(Opcodes.lp_event_msg())
|
||||
|> Out.encode_byte(1)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Scrolling server message (top of screen banner).
|
||||
|
||||
Reference: MaplePacketCreator.serverMessage()
|
||||
"""
|
||||
def scrolling_message(message) do
|
||||
Out.new(Opcodes.lp_event_msg())
|
||||
|> Out.encode_byte(4)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Screenshot request packet (admin tool).
|
||||
Sends a session key to the client for screenshot verification.
|
||||
|
||||
Reference: ClientPool.getScreenshot()
|
||||
"""
|
||||
def screenshot_request(session_key) do
|
||||
Out.new(Opcodes.lp_screen_msg())
|
||||
|> Out.encode_long(session_key)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Start lie detector on player.
|
||||
|
||||
Reference: MaplePacketCreator.sendLieDetector()
|
||||
"""
|
||||
def start_lie_detector do
|
||||
Out.new(Opcodes.lp_lie_detector())
|
||||
|> Out.encode_byte(1) # Start lie detector
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lie detector result packet.
|
||||
|
||||
Status:
|
||||
- 0 = Success
|
||||
- 1 = Failed (wrong answer)
|
||||
- 2 = Timeout
|
||||
- 3 = Error
|
||||
"""
|
||||
def lie_detector_result(status) do
|
||||
Out.new(Opcodes.lp_lie_detector())
|
||||
|> Out.encode_byte(status)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Admin result packet (command acknowledgment).
|
||||
|
||||
Reference: MaplePacketCreator.getAdminResult()
|
||||
"""
|
||||
def admin_result(success, message) do
|
||||
Out.new(Opcodes.lp_admin_result())
|
||||
|> Out.encode_byte(if success, do: 1, else: 0)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Force disconnect packet (kick player).
|
||||
|
||||
Reference: MaplePacketCreator.getForcedDisconnect()
|
||||
"""
|
||||
def force_disconnect(reason \\ 0) do
|
||||
Out.new(Opcodes.lp_forced_stat_ex())
|
||||
|> Out.encode_byte(reason)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Drop Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Spawns a drop on the map (LP_DropItemFromMapObject).
|
||||
|
||||
## Parameters
|
||||
- drop: Drop.t() struct
|
||||
- source_position: Position where drop originated (monster/player position)
|
||||
- animation: Animation type
|
||||
- 1 = animation (drop falls from source)
|
||||
- 2 = no animation (instant spawn)
|
||||
- 3 = spawn disappearing item [Fade]
|
||||
- 4 = spawn disappearing item
|
||||
- delay: Delay before drop appears (in ms)
|
||||
|
||||
Reference: MaplePacketCreator.dropItemFromMapObject()
|
||||
"""
|
||||
def spawn_drop(drop, source_position \\ nil, animation \\ 1, delay \\ 0) do
|
||||
source_pos = source_position || drop.position
|
||||
|
||||
packet = Out.new(Opcodes.lp_drop_item_from_mapobject())
|
||||
|> Out.encode_byte(animation)
|
||||
|> Out.encode_int(drop.oid)
|
||||
|> Out.encode_byte(if drop.meso > 0, do: 1, else: 0) # 1 = mesos, 0 = item
|
||||
|> Out.encode_int(Drop.display_id(drop))
|
||||
|> Out.encode_int(drop.owner_id)
|
||||
|> Out.encode_byte(drop.drop_type)
|
||||
|> Out.encode_short(drop.position.x)
|
||||
|> Out.encode_short(drop.position.y)
|
||||
|> Out.encode_int(0) # Unknown
|
||||
|
||||
# If animation != 2, encode source position
|
||||
packet = if animation != 2 do
|
||||
packet
|
||||
|> Out.encode_short(source_pos.x)
|
||||
|> Out.encode_short(source_pos.y)
|
||||
|> Out.encode_short(delay)
|
||||
else
|
||||
packet
|
||||
end
|
||||
|
||||
# If not meso, encode expiration time
|
||||
packet = if drop.meso == 0 do
|
||||
# Expiration time - for now, send 0 (no expiration)
|
||||
# In full implementation, this would be the item's expiration timestamp
|
||||
Out.encode_long(packet, 0)
|
||||
else
|
||||
packet
|
||||
end
|
||||
|
||||
# Pet pickup byte
|
||||
# 0 = player can pick up, 1 = pet can pick up
|
||||
packet
|
||||
|> Out.encode_short(if drop.player_drop, do: 0, else: 1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a drop from the map (LP_RemoveItemFromMap).
|
||||
|
||||
## Parameters
|
||||
- oid: Drop object ID
|
||||
- animation: Removal animation
|
||||
- 0 = Expire/fade out
|
||||
- 1 = Without animation
|
||||
- 2 = Pickup animation
|
||||
- 4 = Explode animation
|
||||
- 5 = Pet pickup
|
||||
- character_id: Character ID performing the action (for pickup animations)
|
||||
- slot: Pet slot (for pet pickup animation)
|
||||
|
||||
Reference: MaplePacketCreator.removeItemFromMap()
|
||||
"""
|
||||
def remove_drop(oid, animation \\ 1, character_id \\ 0, slot \\ 0) do
|
||||
packet = Out.new(Opcodes.lp_remove_item_from_map())
|
||||
|> Out.encode_byte(animation)
|
||||
|> Out.encode_int(oid)
|
||||
|
||||
# If animation >= 2, encode character ID
|
||||
packet = if animation >= 2 do
|
||||
Out.encode_int(packet, character_id)
|
||||
else
|
||||
packet
|
||||
end
|
||||
|
||||
# If animation == 5 (pet pickup), encode slot
|
||||
packet = if animation == 5 do
|
||||
Out.encode_int(packet, slot)
|
||||
else
|
||||
packet
|
||||
end
|
||||
|
||||
Out.to_data(packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns multiple drops at once (for explosive drops).
|
||||
Broadcasts a spawn packet for each drop with minimal animation.
|
||||
"""
|
||||
def spawn_drops(drops, source_position, animation \\ 2) do
|
||||
Enum.map(drops, fn drop ->
|
||||
spawn_drop(drop, source_position, animation)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends existing drops to a joining player.
|
||||
"""
|
||||
def send_existing_drops(client_pid, drops) do
|
||||
Enum.each(drops, fn drop ->
|
||||
# Only send drops that haven't been picked up
|
||||
if not drop.picked_up do
|
||||
packet = spawn_drop(drop, nil, 2) # No animation
|
||||
send(client_pid, {:send_packet, packet})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Updates pet information in inventory (ModifyInventoryItem).
|
||||
Ported from PetPacket.updatePet()
|
||||
"""
|
||||
def update_pet(pet, item \\ nil, active \\ true) do
|
||||
# Encode inventory update with pet info
|
||||
Out.new(Opcodes.lp_modify_inventory_item())
|
||||
|> Out.encode_byte(0) # Inventory mode
|
||||
|> Out.encode_byte(2) # Update count
|
||||
|> Out.encode_byte(3) # Mode type
|
||||
|> Out.encode_byte(5) # Inventory type (CASH)
|
||||
|> Out.encode_short(pet.inventory_position)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(5) # Inventory type (CASH)
|
||||
|> Out.encode_short(pet.inventory_position)
|
||||
|> Out.encode_byte(3) # Item type for pet
|
||||
|> Out.encode_int(pet.pet_item_id)
|
||||
|> Out.encode_byte(1)
|
||||
|> Out.encode_long(pet.unique_id)
|
||||
|> encode_pet_item_info(pet, item, active)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a pet on the map (LP_SpawnPet).
|
||||
Ported from PetPacket.showPet()
|
||||
|
||||
## Parameters
|
||||
- character_id: Owner's character ID
|
||||
- pet: Pet.t() struct
|
||||
- remove: If true, removes the pet
|
||||
- hunger: If true and removing, shows hunger message
|
||||
"""
|
||||
def spawn_pet(character_id, pet, remove \\ false, hunger \\ false) do
|
||||
packet = Out.new(Opcodes.lp_spawn_pet())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode pet slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, pet.summoned)
|
||||
else
|
||||
Out.encode_int(packet, pet.summoned)
|
||||
end
|
||||
|
||||
if remove do
|
||||
packet
|
||||
|> Out.encode_byte(0) # Remove flag
|
||||
|> Out.encode_byte(if hunger, do: 1, else: 0)
|
||||
else
|
||||
packet
|
||||
|> Out.encode_byte(1) # Show flag
|
||||
|> Out.encode_byte(0) # Unknown flag
|
||||
|> Out.encode_int(pet.pet_item_id)
|
||||
|> Out.encode_string(pet.name)
|
||||
|> Out.encode_long(pet.unique_id)
|
||||
|> Out.encode_short(pet.position.x)
|
||||
|> Out.encode_short(pet.position.y - 20) # Offset Y slightly
|
||||
|> Out.encode_byte(pet.stance)
|
||||
|> Out.encode_int(pet.position.fh)
|
||||
end
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a pet from the map.
|
||||
Ported from PetPacket.removePet()
|
||||
"""
|
||||
def remove_pet(character_id, slot) do
|
||||
packet = Out.new(Opcodes.lp_spawn_pet())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, slot)
|
||||
else
|
||||
Out.encode_int(packet, slot)
|
||||
end
|
||||
|
||||
packet
|
||||
|> Out.encode_short(0) # Remove flag
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Moves a pet on the map (LP_PetMove).
|
||||
Ported from PetPacket.movePet()
|
||||
"""
|
||||
def move_pet(character_id, pet_unique_id, slot, movement_data) do
|
||||
packet = Out.new(Opcodes.lp_move_pet())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, slot)
|
||||
else
|
||||
Out.encode_int(packet, slot)
|
||||
end
|
||||
|
||||
packet
|
||||
|> Out.encode_long(pet_unique_id)
|
||||
|> Out.encode_bytes(movement_data)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pet chat packet (LP_PetChat).
|
||||
Ported from PetPacket.petChat()
|
||||
"""
|
||||
def pet_chat(character_id, slot, command, text) do
|
||||
packet = Out.new(Opcodes.lp_pet_chat())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, slot)
|
||||
else
|
||||
Out.encode_int(packet, slot)
|
||||
end
|
||||
|
||||
packet
|
||||
|> Out.encode_short(command)
|
||||
|> Out.encode_string(text)
|
||||
|> Out.encode_byte(0) # hasQuoteRing
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pet command response (LP_PetCommand).
|
||||
Ported from PetPacket.commandResponse()
|
||||
|
||||
## Parameters
|
||||
- character_id: Owner's character ID
|
||||
- slot: Pet slot (0-2)
|
||||
- command: Command ID that was executed
|
||||
- success: Whether the command succeeded
|
||||
- food: Whether this was a food command
|
||||
"""
|
||||
def pet_command_response(character_id, slot, command, success, food) do
|
||||
packet = Out.new(Opcodes.lp_pet_command())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, slot)
|
||||
else
|
||||
Out.encode_int(packet, slot)
|
||||
end
|
||||
|
||||
# Command encoding differs for food
|
||||
packet = if command == 1 do
|
||||
Out.encode_byte(packet, 1)
|
||||
else
|
||||
Out.encode_byte(packet, 0)
|
||||
end
|
||||
|
||||
packet = Out.encode_byte(packet, command)
|
||||
|
||||
packet = if command == 1 do
|
||||
# Food command
|
||||
Out.encode_byte(packet, 0)
|
||||
else
|
||||
# Regular command
|
||||
Out.encode_short(packet, if(success, do: 1, else: 0))
|
||||
end
|
||||
|
||||
Out.to_data(packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows own pet level up effect (LP_ShowItemGainInChat).
|
||||
Ported from PetPacket.showOwnPetLevelUp()
|
||||
"""
|
||||
def show_own_pet_level_up(slot) do
|
||||
Out.new(Opcodes.lp_show_item_gain_inchat())
|
||||
|> Out.encode_byte(6) # Type: pet level up
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_int(slot)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows pet level up effect to other players (LP_ShowForeignEffect).
|
||||
Ported from PetPacket.showPetLevelUp()
|
||||
"""
|
||||
def show_pet_level_up(character_id, slot) do
|
||||
Out.new(Opcodes.lp_show_foreign_effect())
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_byte(6) # Type: pet level up
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_int(slot)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pet name change/update (LP_PetNameChanged).
|
||||
Ported from PetPacket.showPetUpdate()
|
||||
"""
|
||||
def pet_name_change(character_id, pet_unique_id, slot) do
|
||||
packet = Out.new(Opcodes.lp_pet_namechange())
|
||||
|> Out.encode_int(character_id)
|
||||
|
||||
# Encode slot based on GMS mode
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_byte(packet, slot)
|
||||
else
|
||||
Out.encode_int(packet, slot)
|
||||
end
|
||||
|
||||
packet
|
||||
|> Out.encode_long(pet_unique_id)
|
||||
|> Out.encode_byte(0) # Unknown
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pet stat update (UPDATE_STATS with PET flag).
|
||||
Ported from PetPacket.petStatUpdate()
|
||||
"""
|
||||
def pet_stat_update(pets) do
|
||||
# Filter summoned pets
|
||||
summoned_pets = Enum.filter(pets, fn pet -> pet.summoned > 0 end)
|
||||
|
||||
packet = Out.new(Opcodes.lp_update_stats())
|
||||
|> Out.encode_byte(0) # Reset mask flag
|
||||
|
||||
# Encode stat mask based on GMS mode
|
||||
pet_stat_value = if Odinsea.Constants.Game.gms?(), do: 0x200000, else: 0x200000
|
||||
|
||||
packet = if Odinsea.Constants.Game.gms?() do
|
||||
Out.encode_long(packet, pet_stat_value)
|
||||
else
|
||||
Out.encode_int(packet, pet_stat_value)
|
||||
end
|
||||
|
||||
# Encode summoned pet unique IDs (up to 3)
|
||||
packet = Enum.reduce(summoned_pets, packet, fn pet, p ->
|
||||
Out.encode_long(p, pet.unique_id)
|
||||
end)
|
||||
|
||||
# Fill remaining slots with empty
|
||||
remaining = 3 - length(summoned_pets)
|
||||
packet = Enum.reduce(1..remaining, packet, fn _, p ->
|
||||
Out.encode_long(p, 0)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_short(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet Encoding Helpers
|
||||
# ============================================================================
|
||||
|
||||
@doc false
|
||||
defp encode_pet_item_info(packet, pet, item, active) do
|
||||
# Encode full pet item info structure
|
||||
# This includes pet stats, name, level, closeness, fullness, flags, etc.
|
||||
packet = packet
|
||||
|> Out.encode_string(pet.name)
|
||||
|> Out.encode_byte(pet.level)
|
||||
|> Out.encode_short(pet.closeness)
|
||||
|> Out.encode_byte(pet.fullness)
|
||||
|> Out.encode_long(if active, do: pet.unique_id, else: 0)
|
||||
|> Out.encode_short(pet.inventory_position)
|
||||
|> Out.encode_int(pet.seconds_left)
|
||||
|> Out.encode_short(pet.flags)
|
||||
|> Out.encode_int(pet.pet_item_id)
|
||||
|
||||
# Add equip info (3 slots: hat, saddle, decor)
|
||||
# For now, encode empty slots
|
||||
packet
|
||||
|> Out.encode_long(0) # Pet equip slot 1
|
||||
|> Out.encode_long(0) # Pet equip slot 2
|
||||
|> Out.encode_long(0) # Pet equip slot 3
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes pet info for character spawn packet.
|
||||
Called when spawning a player with their active pets.
|
||||
"""
|
||||
def encode_spawn_pets(packet, pets) do
|
||||
# Get summoned pets in slot order
|
||||
summoned_pets = pets
|
||||
|> Enum.filter(fn pet -> pet.summoned > 0 end)
|
||||
|> Enum.sort_by(fn pet -> pet.summoned end)
|
||||
|
||||
# Encode 3 pet slots (0 = no pet)
|
||||
{pet1, rest1} = List.pop_at(summoned_pets, 0, nil)
|
||||
{pet2, rest2} = List.pop_at(rest1, 0, nil)
|
||||
{pet3, _} = List.pop_at(rest2, 0, nil)
|
||||
|
||||
packet
|
||||
|> encode_single_pet_for_spawn(pet1)
|
||||
|> encode_single_pet_for_spawn(pet2)
|
||||
|> encode_single_pet_for_spawn(pet3)
|
||||
end
|
||||
|
||||
defp encode_single_pet_for_spawn(packet, nil) do
|
||||
packet
|
||||
|> Out.encode_byte(0) # No pet
|
||||
end
|
||||
|
||||
defp encode_single_pet_for_spawn(packet, pet) do
|
||||
packet
|
||||
|> Out.encode_byte(1) # Has pet
|
||||
|> Out.encode_int(pet.pet_item_id)
|
||||
|> Out.encode_string(pet.name)
|
||||
|> Out.encode_long(pet.unique_id)
|
||||
|> Out.encode_short(pet.position.x)
|
||||
|> Out.encode_short(pet.position.y)
|
||||
|> Out.encode_byte(pet.stance)
|
||||
|> Out.encode_int(pet.position.fh)
|
||||
|> Out.encode_byte(pet.level)
|
||||
|> Out.encode_short(pet.closeness)
|
||||
|> Out.encode_byte(pet.fullness)
|
||||
|> Out.encode_short(pet.flags)
|
||||
|> Out.encode_int(0) # Pet equip item ID 1
|
||||
|> Out.encode_int(0) # Pet equip item ID 2
|
||||
|> Out.encode_int(0) # Pet equip item ID 3
|
||||
|> Out.encode_int(0) # Pet equip item ID 4
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,6 +35,25 @@ defmodule Odinsea.Channel.Players do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player with full character state.
|
||||
Extracts relevant fields from character state.
|
||||
"""
|
||||
def add_character(character_id, %{} = character_state) do
|
||||
player_data = %{
|
||||
character_id: character_id,
|
||||
name: character_state.name,
|
||||
map_id: character_state.map_id,
|
||||
level: character_state.level,
|
||||
job: character_state.job,
|
||||
gm: Map.get(character_state, :gm, 0),
|
||||
client_pid: character_state.client_pid
|
||||
}
|
||||
|
||||
:ets.insert(@table, {character_id, player_data})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the channel storage.
|
||||
"""
|
||||
|
||||
@@ -225,4 +225,161 @@ defmodule Odinsea.Constants.Game do
|
||||
_ -> "Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Skill & Attack Constants (for Anti-Cheat)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Returns the attack delay for a skill (in ticks).
|
||||
Used for speed hack detection.
|
||||
"""
|
||||
def get_attack_delay(skill_id) do
|
||||
if skill_id == 0 do
|
||||
# Normal attack delay
|
||||
300
|
||||
else
|
||||
# Get from skill data or use default
|
||||
get_skill_delay(skill_id) || 300
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the skill damage percentage.
|
||||
"""
|
||||
def get_skill_damage(skill_id) do
|
||||
# Default skill damage, would be loaded from WZ in production
|
||||
case skill_id do
|
||||
0 -> 100
|
||||
# Common skills
|
||||
1000 -> 40
|
||||
1009 -> 3000
|
||||
1020 -> 1
|
||||
# Warrior
|
||||
1001004 -> 150
|
||||
1001005 -> 200
|
||||
1101006 -> 180
|
||||
# Mage
|
||||
2001008 -> 120
|
||||
2101004 -> 130
|
||||
2201004 -> 130
|
||||
# Bowman
|
||||
3101005 -> 150
|
||||
3201005 -> 150
|
||||
# Thief
|
||||
4001334 -> 130
|
||||
4101005 -> 140
|
||||
4201005 -> 140
|
||||
# Pirate
|
||||
5001002 -> 160
|
||||
5101004 -> 150
|
||||
5201004 -> 150
|
||||
# Aran
|
||||
21000002 -> 180
|
||||
21100001 -> 190
|
||||
_ -> 100
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the attack range for a skill.
|
||||
"""
|
||||
def get_attack_range(skill_id) do
|
||||
case skill_id do
|
||||
0 -> 100
|
||||
# Ranged skills
|
||||
3101005 -> 500
|
||||
3201005 -> 500
|
||||
4001334 -> 250
|
||||
4101005 -> 250
|
||||
4121007 -> 300
|
||||
4201005 -> 150
|
||||
4221007 -> 600
|
||||
# Melee skills
|
||||
_ -> 150
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if skill is a Mu Lung Dojo skill.
|
||||
"""
|
||||
def is_mulung_skill?(skill_id) do
|
||||
skill_id >= 10001000 && skill_id < 10002000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if skill is a Pyramid skill.
|
||||
"""
|
||||
def is_pyramid_skill?(skill_id) do
|
||||
skill_id >= 1020 && skill_id <= 1022
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if skill has no delay.
|
||||
"""
|
||||
def is_no_delay_skill?(skill_id) do
|
||||
# Skills that bypass attack delay checks
|
||||
skill_id in [3101005, 1009, 1020]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if skill is a magic charge skill.
|
||||
"""
|
||||
def is_magic_charge_skill?(skill_id) do
|
||||
skill_id in [2121001, 2221001, 2321001]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if skill is an event skill.
|
||||
"""
|
||||
def is_event_skill?(skill_id) do
|
||||
# Skills only usable in specific event maps
|
||||
skill_id >= 90001000 && skill_id < 90002000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the linked Aran skill (for skill linking).
|
||||
"""
|
||||
def get_linked_aran_skill(skill_id) do
|
||||
# Handle Aran skill linking
|
||||
if div(skill_id, 10000) == 21 do
|
||||
# Convert Aran skills to regular warrior equivalents for linking
|
||||
base = rem(skill_id, 10000)
|
||||
cond do
|
||||
base == 1005 -> 1101005
|
||||
base == 1004 -> 1101006
|
||||
true -> skill_id
|
||||
end
|
||||
else
|
||||
skill_id
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Private Helpers
|
||||
# =============================================================================
|
||||
|
||||
defp get_skill_delay(skill_id) do
|
||||
case skill_id do
|
||||
0 -> 300
|
||||
1000 -> 600
|
||||
1004 -> 600
|
||||
1005 -> 900
|
||||
1001004 -> 960
|
||||
1001005 -> 1260
|
||||
1101006 -> 1050
|
||||
2001008 -> 810
|
||||
2101004 -> 810
|
||||
2201004 -> 810
|
||||
3101005 -> 840
|
||||
3201005 -> 840
|
||||
4001334 -> 600
|
||||
4101005 -> 720
|
||||
4201005 -> 720
|
||||
5001002 -> 600
|
||||
5101004 -> 660
|
||||
5201004 -> 660
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
335
lib/odinsea/game/attack_info.ex
Normal file
335
lib/odinsea/game/attack_info.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
defmodule Odinsea.Game.AttackInfo do
|
||||
use Bitwise
|
||||
@moduledoc """
|
||||
Attack information struct and parser functions.
|
||||
Ported from src/handling/channel/handler/AttackInfo.java and DamageParse.java
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
require Logger
|
||||
|
||||
@type attack_type :: :melee | :ranged | :magic | :melee_with_mirror | :ranged_with_shadowpartner
|
||||
|
||||
@type damage_entry :: %{
|
||||
mob_oid: integer(),
|
||||
damages: list({integer(), boolean()})
|
||||
}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
skill: integer(),
|
||||
charge: integer(),
|
||||
last_attack_tick: integer(),
|
||||
all_damage: list(damage_entry()),
|
||||
position: %{x: integer(), y: integer()},
|
||||
display: integer(),
|
||||
hits: integer(),
|
||||
targets: integer(),
|
||||
tbyte: integer(),
|
||||
speed: integer(),
|
||||
csstar: integer(),
|
||||
aoe: integer(),
|
||||
slot: integer(),
|
||||
unk: integer(),
|
||||
delay: integer(),
|
||||
real: boolean(),
|
||||
attack_type: attack_type()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:skill,
|
||||
:charge,
|
||||
:last_attack_tick,
|
||||
:all_damage,
|
||||
:position,
|
||||
:display,
|
||||
:hits,
|
||||
:targets,
|
||||
:tbyte,
|
||||
:speed,
|
||||
:csstar,
|
||||
:aoe,
|
||||
:slot,
|
||||
:unk,
|
||||
:delay,
|
||||
:real,
|
||||
:attack_type
|
||||
]
|
||||
|
||||
@doc """
|
||||
Parse melee/close-range attack packet (CP_CLOSE_RANGE_ATTACK).
|
||||
Ported from DamageParse.parseDmgM()
|
||||
"""
|
||||
def parse_melee_attack(packet, opts \\ []) do
|
||||
energy = Keyword.get(opts, :energy, false)
|
||||
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip GMS-specific fields (9 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 9)
|
||||
|
||||
# Handle charge skills
|
||||
{charge, packet} =
|
||||
case skill do
|
||||
5_101_004 -> In.decode_int(packet) # Corkscrew
|
||||
15_101_003 -> In.decode_int(packet) # Cygnus corkscrew
|
||||
5_201_002 -> In.decode_int(packet) # Gernard
|
||||
14_111_006 -> In.decode_int(packet) # Poison bomb
|
||||
4_341_002 -> In.decode_int(packet)
|
||||
4_341_003 -> In.decode_int(packet)
|
||||
5_301_001 -> In.decode_int(packet)
|
||||
5_300_007 -> In.decode_int(packet)
|
||||
_ -> {0, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
# Meso Explosion special handling
|
||||
if skill == 4_211_006 do
|
||||
parse_meso_explosion(packet, %__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :melee
|
||||
})
|
||||
else
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :melee
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parse ranged attack packet (CP_RANGED_ATTACK).
|
||||
Ported from DamageParse.parseDmgR()
|
||||
"""
|
||||
def parse_ranged_attack(packet) do
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip GMS-specific fields (10 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 10)
|
||||
|
||||
# Handle special skills with extra 4 bytes
|
||||
{_, packet} =
|
||||
case skill do
|
||||
3_121_004 -> In.skip(packet, 4) # Hurricane
|
||||
3_221_001 -> In.skip(packet, 4) # Pierce
|
||||
5_221_004 -> In.skip(packet, 4) # Rapidfire
|
||||
13_111_002 -> In.skip(packet, 4) # Cygnus Hurricane
|
||||
33_121_009 -> In.skip(packet, 4)
|
||||
35_001_001 -> In.skip(packet, 4)
|
||||
35_101_009 -> In.skip(packet, 4)
|
||||
23_121_000 -> In.skip(packet, 4)
|
||||
5_311_002 -> In.skip(packet, 4)
|
||||
_ -> {nil, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
{slot, packet} = In.decode_short(packet)
|
||||
{csstar, packet} = In.decode_short(packet)
|
||||
{aoe, packet} = In.decode_byte(packet)
|
||||
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
# Skip 4 bytes before position
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: -1,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
csstar: csstar,
|
||||
aoe: aoe,
|
||||
slot: slot,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :ranged
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parse magic attack packet (CP_MAGIC_ATTACK).
|
||||
Ported from DamageParse.parseDmgMa()
|
||||
"""
|
||||
def parse_magic_attack(packet) do
|
||||
# Decode attack header
|
||||
{tbyte, packet} = In.decode_byte(packet)
|
||||
targets = (tbyte >>> 4) &&& 0xF
|
||||
hits = tbyte &&& 0xF
|
||||
|
||||
{skill, packet} = In.decode_int(packet)
|
||||
|
||||
# Return error if invalid skill
|
||||
if skill >= 91_000_000 do
|
||||
{:error, :invalid_skill}
|
||||
else
|
||||
# Skip GMS-specific fields (9 bytes in GMS)
|
||||
{_, packet} = In.skip(packet, 9)
|
||||
|
||||
# Handle charge skills
|
||||
{charge, packet} =
|
||||
if is_magic_charge_skill?(skill) do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
{-1, packet}
|
||||
end
|
||||
|
||||
{unk, packet} = In.decode_byte(packet)
|
||||
{display, packet} = In.decode_ushort(packet)
|
||||
|
||||
# Skip 4 bytes (big bang) + 1 byte (weapon class)
|
||||
{_, packet} = In.skip(packet, 5)
|
||||
|
||||
{speed, packet} = In.decode_byte(packet)
|
||||
{last_attack_tick, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 4 bytes (padding)
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
# Parse damage for each target
|
||||
{all_damage, packet} = parse_damage_targets(packet, targets, hits, [])
|
||||
|
||||
{position, _packet} = In.decode_point(packet)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
skill: skill,
|
||||
charge: charge,
|
||||
last_attack_tick: last_attack_tick,
|
||||
all_damage: all_damage,
|
||||
position: position,
|
||||
display: display,
|
||||
hits: hits,
|
||||
targets: targets,
|
||||
tbyte: tbyte,
|
||||
speed: speed,
|
||||
unk: unk,
|
||||
real: true,
|
||||
attack_type: :magic
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp parse_damage_targets(_packet, 0, _hits, acc), do: {Enum.reverse(acc), <<>>}
|
||||
|
||||
defp parse_damage_targets(packet, targets_remaining, hits, acc) do
|
||||
{mob_oid, packet} = In.decode_int(packet)
|
||||
|
||||
# Skip 12 bytes: [1] Always 6?, [3] unk, [4] Pos1, [4] Pos2
|
||||
{_, packet} = In.skip(packet, 12)
|
||||
|
||||
{delay, packet} = In.decode_short(packet)
|
||||
|
||||
# Parse damage values for this target
|
||||
{damages, packet} = parse_damage_hits(packet, hits, [])
|
||||
|
||||
# Skip 4 bytes: CRC of monster [Wz Editing]
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
|
||||
damage_entry = %{
|
||||
mob_oid: mob_oid,
|
||||
damages: damages,
|
||||
delay: delay
|
||||
}
|
||||
|
||||
parse_damage_targets(packet, targets_remaining - 1, hits, [damage_entry | acc])
|
||||
end
|
||||
|
||||
defp parse_damage_hits(_packet, 0, acc), do: {Enum.reverse(acc), <<>>}
|
||||
|
||||
defp parse_damage_hits(packet, hits_remaining, acc) do
|
||||
{damage, packet} = In.decode_int(packet)
|
||||
# Second boolean is for critical hit (not used in v342)
|
||||
parse_damage_hits(packet, hits_remaining - 1, [{damage, false} | acc])
|
||||
end
|
||||
|
||||
defp parse_meso_explosion(packet, attack_info) do
|
||||
# TODO: Implement meso explosion parsing
|
||||
# For now, return empty damage list
|
||||
Logger.warning("Meso explosion not yet implemented")
|
||||
|
||||
{:ok,
|
||||
%{attack_info | all_damage: [], position: %{x: 0, y: 0}}}
|
||||
end
|
||||
|
||||
defp is_magic_charge_skill?(skill_id) do
|
||||
skill_id in [
|
||||
# Big Bang skills
|
||||
2_121_001,
|
||||
2_221_001,
|
||||
2_321_001,
|
||||
# Elemental Charge skills
|
||||
12_111_004
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,7 @@ defmodule Odinsea.Game.Character do
|
||||
|
||||
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Game.{Inventory, InventoryType}
|
||||
alias Odinsea.Game.{Inventory, InventoryType, Pet}
|
||||
alias Odinsea.Net.Packet.Out
|
||||
|
||||
# ============================================================================
|
||||
@@ -92,6 +92,8 @@ defmodule Odinsea.Game.Character do
|
||||
:skin_color,
|
||||
:hair,
|
||||
:face,
|
||||
# GM Level (0 = normal player, >0 = GM)
|
||||
:gm,
|
||||
# Stats
|
||||
:stats,
|
||||
# Position & Map
|
||||
@@ -131,6 +133,7 @@ defmodule Odinsea.Game.Character do
|
||||
skin_color: byte(),
|
||||
hair: non_neg_integer(),
|
||||
face: non_neg_integer(),
|
||||
gm: non_neg_integer(),
|
||||
stats: Stats.t(),
|
||||
map_id: non_neg_integer(),
|
||||
position: Position.t(),
|
||||
@@ -513,6 +516,7 @@ defmodule Odinsea.Game.Character do
|
||||
skin_color: db_char.skin_color,
|
||||
hair: db_char.hair,
|
||||
face: db_char.face,
|
||||
gm: db_char.gm,
|
||||
stats: stats,
|
||||
map_id: db_char.map_id,
|
||||
position: position,
|
||||
@@ -592,10 +596,258 @@ defmodule Odinsea.Game.Character do
|
||||
|
||||
# Save character base data
|
||||
result = Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
|
||||
|
||||
# Save inventories
|
||||
Odinsea.Database.Context.save_character_inventory(state.character_id, state.inventories)
|
||||
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives EXP to the character.
|
||||
Handles level-up, EXP calculation, and packet broadcasting.
|
||||
"""
|
||||
def gain_exp(character_pid, exp_amount, is_highest_damage \\ false) when is_pid(character_pid) do
|
||||
GenServer.cast(character_pid, {:gain_exp, exp_amount, is_highest_damage})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Pet API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Gets a pet by slot index (0-2 for the 3 pet slots).
|
||||
"""
|
||||
def get_pet(character_id, slot) do
|
||||
GenServer.call(via_tuple(character_id), {:get_pet, slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all summoned pets.
|
||||
"""
|
||||
def get_summoned_pets(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_summoned_pets)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a pet from inventory to the map.
|
||||
"""
|
||||
def spawn_pet(character_id, inventory_slot, lead \\ false) do
|
||||
GenServer.call(via_tuple(character_id), {:spawn_pet, inventory_slot, lead})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Despawns a pet from the map.
|
||||
"""
|
||||
def despawn_pet(character_id, slot) do
|
||||
GenServer.call(via_tuple(character_id), {:despawn_pet, slot})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a pet's data (after feeding, command, etc.).
|
||||
"""
|
||||
def update_pet(character_id, pet) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_pet, pet})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a pet's position.
|
||||
"""
|
||||
def update_pet_position(character_id, slot, position) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_pet_position, slot, position})
|
||||
end
|
||||
|
||||
def gain_exp(character_id, exp_amount, is_highest_damage) when is_integer(character_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> gain_exp(pid, exp_amount, is_highest_damage)
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks - EXP Gain
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_cast({:gain_exp, exp_amount, is_highest_damage}, state) do
|
||||
# Calculate EXP needed for next level
|
||||
exp_needed = calculate_exp_needed(state.level)
|
||||
|
||||
# Add EXP
|
||||
new_exp = state.exp + exp_amount
|
||||
|
||||
# Check for level up
|
||||
{new_state, leveled_up} =
|
||||
if new_exp >= exp_needed and state.level < 200 do
|
||||
# Level up!
|
||||
new_level = state.level + 1
|
||||
|
||||
# Calculate stat gains (simple formula for now)
|
||||
# TODO: Use job-specific stat gain formulas
|
||||
hp_gain = 10 + div(state.stats.str, 10)
|
||||
mp_gain = 5 + div(state.stats.int, 10)
|
||||
|
||||
new_stats = %{
|
||||
state.stats
|
||||
| max_hp: state.stats.max_hp + hp_gain,
|
||||
max_mp: state.stats.max_mp + mp_gain,
|
||||
hp: state.stats.max_hp + hp_gain,
|
||||
mp: state.stats.max_mp + mp_gain
|
||||
}
|
||||
|
||||
updated_state = %{
|
||||
state
|
||||
| level: new_level,
|
||||
exp: new_exp - exp_needed,
|
||||
stats: new_stats,
|
||||
remaining_ap: state.remaining_ap + 5
|
||||
}
|
||||
|
||||
Logger.info("Character #{state.name} leveled up to #{new_level}!")
|
||||
|
||||
# TODO: Send level-up packet to client
|
||||
# TODO: Broadcast level-up effect to map
|
||||
|
||||
{updated_state, true}
|
||||
else
|
||||
{%{state | exp: new_exp}, false}
|
||||
end
|
||||
|
||||
# TODO: Send EXP gain packet to client
|
||||
# TODO: If highest damage, send bonus message
|
||||
|
||||
Logger.debug(
|
||||
"Character #{state.name} gained #{exp_amount} EXP (total: #{new_state.exp}, level: #{new_state.level})"
|
||||
)
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks - Pet Functions
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_pet, slot}, _from, state) do
|
||||
# Find pet by slot (1, 2, or 3)
|
||||
pet = Enum.find(state.pets, fn p -> p.summoned == slot end)
|
||||
|
||||
if pet do
|
||||
{:reply, {:ok, pet}, state}
|
||||
else
|
||||
{:reply, {:error, :pet_not_found}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_summoned_pets, _from, state) do
|
||||
# Return list of {slot, pet} tuples for all summoned pets
|
||||
summoned = state.pets
|
||||
|> Enum.filter(fn p -> p.summoned > 0 end)
|
||||
|> Enum.map(fn p -> {p.summoned, p} end)
|
||||
|
||||
{:reply, summoned, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:spawn_pet, inventory_slot, lead}, _from, state) do
|
||||
# Get pet from cash inventory
|
||||
cash_inv = Map.get(state.inventories, :cash, Inventory.new(:cash))
|
||||
item = Inventory.get_item(cash_inv, inventory_slot)
|
||||
|
||||
cond do
|
||||
is_nil(item) ->
|
||||
{:reply, {:error, :item_not_found}, state}
|
||||
|
||||
is_nil(item.pet) ->
|
||||
{:reply, {:error, :not_a_pet}, state}
|
||||
|
||||
true ->
|
||||
# Find available slot (1, 2, or 3)
|
||||
used_slots = state.pets |> Enum.map(& &1.summoned) |> Enum.filter(& &1 > 0)
|
||||
available_slots = [1, 2, 3] -- used_slots
|
||||
|
||||
if available_slots == [] do
|
||||
{:reply, {:error, :no_slots_available}, state}
|
||||
else
|
||||
slot = if lead, do: 1, else: List.first(available_slots)
|
||||
|
||||
# Update pet with summoned slot and position
|
||||
pet = item.pet
|
||||
|> Pet.set_summoned(slot)
|
||||
|> Pet.set_inventory_position(inventory_slot)
|
||||
|> Pet.update_position(state.position.x, state.position.y)
|
||||
|
||||
# Add or update pet in state
|
||||
existing_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
|
||||
new_pets = if existing_index do
|
||||
List.replace_at(state.pets, existing_index, pet)
|
||||
else
|
||||
[pet | state.pets]
|
||||
end
|
||||
|
||||
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
||||
{:reply, {:ok, pet}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:despawn_pet, slot}, _from, state) do
|
||||
case Enum.find(state.pets, fn p -> p.summoned == slot end) do
|
||||
nil ->
|
||||
{:reply, {:error, :pet_not_found}, state}
|
||||
|
||||
pet ->
|
||||
# Set summoned to 0
|
||||
updated_pet = Pet.set_summoned(pet, 0)
|
||||
|
||||
# Update in state
|
||||
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
new_pets = List.replace_at(state.pets, pet_index, updated_pet)
|
||||
|
||||
new_state = %{state | pets: new_pets, updated_at: DateTime.utc_now()}
|
||||
{:reply, {:ok, updated_pet}, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_pet, pet}, state) do
|
||||
# Find and update pet
|
||||
pet_index = Enum.find_index(state.pets, fn p -> p.unique_id == pet.unique_id end)
|
||||
|
||||
new_pets = if pet_index do
|
||||
List.replace_at(state.pets, pet_index, pet)
|
||||
else
|
||||
[pet | state.pets]
|
||||
end
|
||||
|
||||
{:noreply, %{state | pets: new_pets, updated_at: DateTime.utc_now()}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_pet_position, slot, position}, state) do
|
||||
# Find pet by slot and update position
|
||||
case Enum.find_index(state.pets, fn p -> p.summoned == slot end) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
index ->
|
||||
pet = Enum.at(state.pets, index)
|
||||
updated_pet = Pet.update_position(pet, position.x, position.y, position.fh, position.stance)
|
||||
new_pets = List.replace_at(state.pets, index, updated_pet)
|
||||
|
||||
{:noreply, %{state | pets: new_pets}}
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate EXP needed to reach next level
|
||||
defp calculate_exp_needed(level) when level >= 200, do: 0
|
||||
|
||||
defp calculate_exp_needed(level) do
|
||||
# Simple formula: level^3 + 100 * level
|
||||
# TODO: Use actual MapleStory EXP table
|
||||
level * level * level + 100 * level
|
||||
end
|
||||
end
|
||||
|
||||
238
lib/odinsea/game/damage_calc.ex
Normal file
238
lib/odinsea/game/damage_calc.ex
Normal file
@@ -0,0 +1,238 @@
|
||||
defmodule Odinsea.Game.DamageCalc do
|
||||
use Bitwise
|
||||
@moduledoc """
|
||||
Damage calculation and application module.
|
||||
Ported from src/handling/channel/handler/DamageParse.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{AttackInfo, Character, Map, Monster}
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Apply attack to monsters on the map.
|
||||
Ported from DamageParse.applyAttack()
|
||||
|
||||
Returns {:ok, total_damage} or {:error, reason}
|
||||
"""
|
||||
def apply_attack(attack_info, character_pid, map_pid, channel_id) do
|
||||
with {:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_data} <- Map.get_monsters(map_pid) do
|
||||
# Check if character is alive
|
||||
if not character.alive? do
|
||||
Logger.warning("Character #{character.name} attacking while dead")
|
||||
{:error, :attacking_while_dead}
|
||||
else
|
||||
# Calculate max damage per monster
|
||||
max_damage_per_monster = calculate_max_damage(character, attack_info)
|
||||
|
||||
# Apply damage to each targeted monster
|
||||
total_damage =
|
||||
Enum.reduce(attack_info.all_damage, 0, fn damage_entry, acc_total ->
|
||||
apply_damage_to_monster(
|
||||
damage_entry,
|
||||
attack_info,
|
||||
character,
|
||||
map_pid,
|
||||
channel_id,
|
||||
max_damage_per_monster
|
||||
) + acc_total
|
||||
end)
|
||||
|
||||
Logger.debug("Attack applied: #{total_damage} total damage to #{length(attack_info.all_damage)} monsters")
|
||||
|
||||
# Broadcast attack packet to other players
|
||||
broadcast_attack(attack_info, character, map_pid, channel_id)
|
||||
|
||||
{:ok, total_damage}
|
||||
end
|
||||
else
|
||||
error ->
|
||||
Logger.error("Failed to apply attack: #{inspect(error)}")
|
||||
{:error, :apply_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp apply_damage_to_monster(damage_entry, attack_info, character, map_pid, channel_id, max_damage) do
|
||||
# Calculate total damage to this monster
|
||||
total_damage =
|
||||
Enum.reduce(damage_entry.damages, 0, fn {damage, _crit}, acc ->
|
||||
# Cap damage at max allowed
|
||||
capped_damage = min(damage, trunc(max_damage))
|
||||
acc + capped_damage
|
||||
end)
|
||||
|
||||
if total_damage > 0 do
|
||||
# Apply damage via Map module
|
||||
case Map.damage_monster(map_pid, damage_entry.mob_oid, character.id, total_damage) do
|
||||
{:ok, :killed} ->
|
||||
Logger.debug("Monster #{damage_entry.mob_oid} killed by #{character.name}")
|
||||
total_damage
|
||||
|
||||
{:ok, :damaged} ->
|
||||
total_damage
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to damage monster #{damage_entry.mob_oid}: #{inspect(reason)}")
|
||||
0
|
||||
end
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_max_damage(character, attack_info) do
|
||||
# Base damage calculation
|
||||
# TODO: Implement full damage formula with stats, weapon attack, skill damage, etc.
|
||||
# For now, use a simple formula based on level and stats
|
||||
|
||||
base_damage =
|
||||
cond do
|
||||
# Magic attack
|
||||
attack_info.attack_type == :magic ->
|
||||
character.stats.int * 5 + character.stats.luk + character.level * 10
|
||||
|
||||
# Ranged attack
|
||||
attack_info.attack_type == :ranged or
|
||||
attack_info.attack_type == :ranged_with_shadowpartner ->
|
||||
character.stats.dex * 5 + character.stats.str + character.level * 10
|
||||
|
||||
# Melee attack
|
||||
true ->
|
||||
character.stats.str * 5 + character.stats.dex + character.level * 10
|
||||
end
|
||||
|
||||
# Apply skill multiplier if skill is used
|
||||
skill_multiplier =
|
||||
if attack_info.skill > 0 do
|
||||
# TODO: Get actual skill damage multiplier from SkillFactory
|
||||
2.0
|
||||
else
|
||||
1.0
|
||||
end
|
||||
|
||||
# Calculate max damage per hit
|
||||
max_damage_per_hit = base_damage * skill_multiplier
|
||||
|
||||
# For now, return a reasonable cap
|
||||
# TODO: Implement actual damage cap from config
|
||||
min(max_damage_per_hit, 999_999)
|
||||
end
|
||||
|
||||
defp broadcast_attack(attack_info, character, map_pid, channel_id) do
|
||||
# Build attack packet based on attack type
|
||||
attack_packet =
|
||||
case attack_info.attack_type do
|
||||
:melee ->
|
||||
build_melee_attack_packet(attack_info, character)
|
||||
|
||||
:ranged ->
|
||||
build_ranged_attack_packet(attack_info, character)
|
||||
|
||||
:magic ->
|
||||
build_magic_attack_packet(attack_info, character)
|
||||
|
||||
_ ->
|
||||
build_melee_attack_packet(attack_info, character)
|
||||
end
|
||||
|
||||
# Broadcast to all players on map except attacker
|
||||
Map.broadcast_except(
|
||||
character.map_id,
|
||||
channel_id,
|
||||
character.id,
|
||||
attack_packet
|
||||
)
|
||||
end
|
||||
|
||||
defp build_melee_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_close_range_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp build_ranged_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_ranged_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp build_magic_attack_packet(attack_info, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_magic_attack())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(attack_info.tbyte)
|
||||
|> Out.encode_byte(character.stats.skill_level)
|
||||
|> Out.encode_int(attack_info.skill)
|
||||
|> Out.encode_byte(attack_info.display &&& 0xFF)
|
||||
|> Out.encode_byte((attack_info.display >>> 8) &&& 0xFF)
|
||||
|> Out.encode_byte(attack_info.speed)
|
||||
|> Out.encode_int(attack_info.last_attack_tick)
|
||||
|
||||
# Encode damage for each target
|
||||
packet =
|
||||
Enum.reduce(attack_info.all_damage, packet, fn damage_entry, acc ->
|
||||
acc
|
||||
|> Out.encode_int(damage_entry.mob_oid)
|
||||
|> encode_damage_hits(damage_entry.damages)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(attack_info.position.x)
|
||||
|> Out.encode_short(attack_info.position.y)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_damage_hits(packet, damages) do
|
||||
Enum.reduce(damages, packet, fn {damage, _crit}, acc ->
|
||||
acc |> Out.encode_int(damage)
|
||||
end)
|
||||
end
|
||||
end
|
||||
200
lib/odinsea/game/drop.ex
Normal file
200
lib/odinsea/game/drop.ex
Normal file
@@ -0,0 +1,200 @@
|
||||
defmodule Odinsea.Game.Drop do
|
||||
@moduledoc """
|
||||
Represents a drop item on a map.
|
||||
Ported from Java server.maps.MapleMapItem
|
||||
|
||||
Drops can be:
|
||||
- Item drops (equipment, use, setup, etc items)
|
||||
- Meso drops (gold/money)
|
||||
|
||||
Drop ownership determines who can loot:
|
||||
- Type 0: Timeout for non-owner only
|
||||
- Type 1: Timeout for non-owner's party
|
||||
- Type 2: Free-for-all (FFA)
|
||||
- Type 3: Explosive/FFA (instant FFA)
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
oid: integer(),
|
||||
item_id: integer(),
|
||||
quantity: integer(),
|
||||
meso: integer(),
|
||||
owner_id: integer(),
|
||||
drop_type: integer(),
|
||||
position: %{x: integer(), y: integer()},
|
||||
source_position: %{x: integer(), y: integer()} | nil,
|
||||
quest_id: integer(),
|
||||
player_drop: boolean(),
|
||||
individual_reward: boolean(),
|
||||
picked_up: boolean(),
|
||||
created_at: integer(),
|
||||
expire_time: integer() | nil,
|
||||
public_time: integer() | nil,
|
||||
dropper_oid: integer() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:oid,
|
||||
:item_id,
|
||||
:quantity,
|
||||
:meso,
|
||||
:owner_id,
|
||||
:drop_type,
|
||||
:position,
|
||||
:source_position,
|
||||
:quest_id,
|
||||
:player_drop,
|
||||
:individual_reward,
|
||||
:picked_up,
|
||||
:created_at,
|
||||
:expire_time,
|
||||
:public_time,
|
||||
:dropper_oid
|
||||
]
|
||||
|
||||
# Default drop expiration times (milliseconds)
|
||||
@default_expire_time 120_000 # 2 minutes
|
||||
@default_public_time 60_000 # 1 minute until FFA
|
||||
|
||||
@doc """
|
||||
Creates a new item drop.
|
||||
"""
|
||||
def new_item_drop(oid, item_id, quantity, owner_id, position, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, 0)
|
||||
quest_id = Keyword.get(opts, :quest_id, -1)
|
||||
individual_reward = Keyword.get(opts, :individual_reward, false)
|
||||
dropper_oid = Keyword.get(opts, :dropper_oid, nil)
|
||||
source_position = Keyword.get(opts, :source_position, nil)
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
%__MODULE__{
|
||||
oid: oid,
|
||||
item_id: item_id,
|
||||
quantity: quantity,
|
||||
meso: 0,
|
||||
owner_id: owner_id,
|
||||
drop_type: drop_type,
|
||||
position: position,
|
||||
source_position: source_position,
|
||||
quest_id: quest_id,
|
||||
player_drop: false,
|
||||
individual_reward: individual_reward,
|
||||
picked_up: false,
|
||||
created_at: now,
|
||||
expire_time: now + @default_expire_time,
|
||||
public_time: if(drop_type < 2, do: now + @default_public_time, else: 0),
|
||||
dropper_oid: dropper_oid
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new meso drop.
|
||||
"""
|
||||
def new_meso_drop(oid, amount, owner_id, position, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, 0)
|
||||
individual_reward = Keyword.get(opts, :individual_reward, false)
|
||||
dropper_oid = Keyword.get(opts, :dropper_oid, nil)
|
||||
source_position = Keyword.get(opts, :source_position, nil)
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
%__MODULE__{
|
||||
oid: oid,
|
||||
item_id: 0,
|
||||
quantity: 0,
|
||||
meso: amount,
|
||||
owner_id: owner_id,
|
||||
drop_type: drop_type,
|
||||
position: position,
|
||||
source_position: source_position,
|
||||
quest_id: -1,
|
||||
player_drop: false,
|
||||
individual_reward: individual_reward,
|
||||
picked_up: false,
|
||||
created_at: now,
|
||||
expire_time: now + @default_expire_time,
|
||||
public_time: if(drop_type < 2, do: now + @default_public_time, else: 0),
|
||||
dropper_oid: dropper_oid
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks the drop as picked up.
|
||||
"""
|
||||
def mark_picked_up(%__MODULE__{} = drop) do
|
||||
%{drop | picked_up: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the drop should expire based on current time.
|
||||
"""
|
||||
def should_expire?(%__MODULE__{} = drop, now) do
|
||||
not drop.picked_up and drop.expire_time != nil and drop.expire_time < now
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the drop has become public (FFA) based on current time.
|
||||
"""
|
||||
def is_public_time?(%__MODULE__{} = drop, now) do
|
||||
not drop.picked_up and drop.public_time != nil and drop.public_time < now
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a drop is visible to a specific character.
|
||||
Considers quest requirements and individual rewards.
|
||||
"""
|
||||
def visible_to?(%__MODULE__{} = drop, character_id, _quest_status) do
|
||||
# Individual rewards only visible to owner
|
||||
if drop.individual_reward do
|
||||
drop.owner_id == character_id
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a meso drop.
|
||||
"""
|
||||
def meso?(%__MODULE__{} = drop) do
|
||||
drop.meso > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the display ID (item_id for items, meso amount for meso).
|
||||
"""
|
||||
def display_id(%__MODULE__{} = drop) do
|
||||
if drop.meso > 0 do
|
||||
drop.meso
|
||||
else
|
||||
drop.item_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character can loot this drop.
|
||||
"""
|
||||
def can_loot?(%__MODULE__{} = drop, character_id, now) do
|
||||
# If already picked up, can't loot
|
||||
if drop.picked_up do
|
||||
false
|
||||
else
|
||||
# Check ownership rules based on drop type
|
||||
case drop.drop_type do
|
||||
0 ->
|
||||
# Timeout for non-owner only
|
||||
drop.owner_id == character_id or is_public_time?(drop, now)
|
||||
1 ->
|
||||
# Timeout for non-owner's party (simplified - treat as FFA after timeout)
|
||||
drop.owner_id == character_id or is_public_time?(drop, now)
|
||||
2 ->
|
||||
# FFA
|
||||
true
|
||||
3 ->
|
||||
# Explosive/FFA (instant FFA)
|
||||
true
|
||||
_ ->
|
||||
# Default to owner-only
|
||||
drop.owner_id == character_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
280
lib/odinsea/game/drop_system.ex
Normal file
280
lib/odinsea/game/drop_system.ex
Normal file
@@ -0,0 +1,280 @@
|
||||
defmodule Odinsea.Game.DropSystem do
|
||||
@moduledoc """
|
||||
Drop creation and management system.
|
||||
Ported from Java server.maps.MapleMap.spawnMobDrop()
|
||||
|
||||
This module handles:
|
||||
- Creating drops when monsters die
|
||||
- Calculating drop positions
|
||||
- Managing drop ownership
|
||||
- Drop expiration
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.{Drop, DropTable, Item, ItemInfo}
|
||||
alias Odinsea.Game.LifeFactory
|
||||
|
||||
require Logger
|
||||
|
||||
# Default drop rate multiplier
|
||||
@default_drop_rate 1.0
|
||||
|
||||
# Drop type constants
|
||||
@drop_type_owner_timeout 0 # Timeout for non-owner
|
||||
@drop_type_party_timeout 1 # Timeout for non-owner's party
|
||||
@drop_type_ffa 2 # Free for all
|
||||
@drop_type_explosive 3 # Explosive (instant FFA)
|
||||
|
||||
@doc """
|
||||
Creates drops for a killed monster.
|
||||
Returns a list of Drop structs.
|
||||
"""
|
||||
@spec create_monster_drops(integer(), integer(), map(), integer(), float()) :: [Drop.t()]
|
||||
def create_monster_drops(mob_id, owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
|
||||
# Get monster stats
|
||||
stats = LifeFactory.get_monster_stats(mob_id)
|
||||
|
||||
if stats == nil do
|
||||
Logger.warning("Cannot create drops - monster stats not found for mob_id #{mob_id}")
|
||||
[]
|
||||
else
|
||||
# Calculate what should drop
|
||||
calculated_drops = DropTable.calculate_drops(mob_id, drop_rate_multiplier)
|
||||
|
||||
# Create Drop structs
|
||||
{drops, _next_oid} =
|
||||
Enum.reduce(calculated_drops, {[], next_oid}, fn drop_data, {acc, oid} ->
|
||||
case create_drop(drop_data, owner_id, position, oid, stats) do
|
||||
nil -> {acc, oid}
|
||||
drop -> {[drop | acc], oid + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.reverse(drops)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a single drop from calculated drop data.
|
||||
"""
|
||||
@spec create_drop({atom(), integer(), integer()}, integer(), map(), integer(), map()) :: Drop.t() | nil
|
||||
def create_drop({:meso, amount, _}, owner_id, position, oid, stats) do
|
||||
# Calculate drop position (small random offset from monster)
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
# Determine drop type based on monster
|
||||
drop_type =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> @drop_type_explosive
|
||||
stats.party_bonus -> @drop_type_party_timeout
|
||||
true -> @drop_type_ffa
|
||||
end
|
||||
|
||||
Drop.new_meso_drop(oid, amount, owner_id, drop_position,
|
||||
drop_type: drop_type,
|
||||
dropper_oid: nil,
|
||||
source_position: position
|
||||
)
|
||||
end
|
||||
|
||||
def create_drop({:item, item_id, quantity}, owner_id, position, oid, stats) do
|
||||
# Validate item exists
|
||||
if ItemInfo.item_exists?(item_id) do
|
||||
# Calculate drop position
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
# Determine drop type
|
||||
drop_type =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> @drop_type_explosive
|
||||
stats.party_bonus -> @drop_type_party_timeout
|
||||
true -> @drop_type_ffa
|
||||
end
|
||||
|
||||
Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
|
||||
drop_type: drop_type,
|
||||
dropper_oid: nil,
|
||||
source_position: position
|
||||
)
|
||||
else
|
||||
Logger.debug("Item #{item_id} not found, skipping drop")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an item drop from a player's inventory.
|
||||
Used when a player drops an item.
|
||||
"""
|
||||
@spec create_player_drop(Item.t(), integer(), map(), integer()) :: Drop.t()
|
||||
def create_player_drop(item, owner_id, position, oid) do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
Drop.new_item_drop(oid, item.item_id, item.quantity, owner_id, drop_position,
|
||||
drop_type: @drop_type_ffa, # Player drops are always FFA
|
||||
player_drop: true
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an equipment drop with randomized stats.
|
||||
"""
|
||||
@spec create_equipment_drop(integer(), integer(), map(), integer(), float()) :: Drop.t() | nil
|
||||
def create_equipment_drop(item_id, owner_id, position, oid, _drop_rate_multiplier \\ 1.0) do
|
||||
# Check if item is equipment
|
||||
case ItemInfo.get_inventory_type(item_id) do
|
||||
:equip ->
|
||||
# Get equipment stats
|
||||
equip = ItemInfo.create_equip(item_id)
|
||||
|
||||
if equip do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
Drop.new_item_drop(oid, item_id, 1, owner_id, drop_position,
|
||||
drop_type: @drop_type_ffa
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not equipment, create regular item drop
|
||||
create_drop({:item, item_id, 1}, owner_id, position, oid, %{})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates a drop position near the source position.
|
||||
"""
|
||||
@spec calculate_drop_position(map()) :: %{x: integer(), y: integer()}
|
||||
def calculate_drop_position(%{x: x, y: y}) do
|
||||
# Random offset within range
|
||||
offset_x = :rand.uniform(80) - 40 # -40 to +40
|
||||
offset_y = :rand.uniform(20) - 10 # -10 to +10
|
||||
|
||||
%{
|
||||
x: x + offset_x,
|
||||
y: y + offset_y
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks and handles drop expiration.
|
||||
Returns updated drops list with expired ones marked.
|
||||
"""
|
||||
@spec check_expiration([Drop.t()], integer()) :: [Drop.t()]
|
||||
def check_expiration(drops, now) do
|
||||
Enum.map(drops, fn drop ->
|
||||
if Drop.should_expire?(drop, now) do
|
||||
Drop.mark_picked_up(drop)
|
||||
else
|
||||
drop
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters out expired and picked up drops.
|
||||
"""
|
||||
@spec cleanup_drops([Drop.t()]) :: [Drop.t()]
|
||||
def cleanup_drops(drops) do
|
||||
Enum.reject(drops, fn drop ->
|
||||
drop.picked_up
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to pick up a drop.
|
||||
Returns {:ok, drop} if successful, {:error, reason} if not.
|
||||
"""
|
||||
@spec pickup_drop(Drop.t(), integer(), integer()) :: {:ok, Drop.t()} | {:error, atom()}
|
||||
def pickup_drop(drop, character_id, now) do
|
||||
cond do
|
||||
drop.picked_up ->
|
||||
{:error, :already_picked_up}
|
||||
|
||||
not Drop.can_loot?(drop, character_id, now) ->
|
||||
{:error, :not_owner}
|
||||
|
||||
true ->
|
||||
{:ok, Drop.mark_picked_up(drop)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all visible drops for a character.
|
||||
"""
|
||||
@spec get_visible_drops([Drop.t()], integer(), map()) :: [Drop.t()]
|
||||
def get_visible_drops(drops, character_id, quest_status) do
|
||||
Enum.filter(drops, fn drop ->
|
||||
Drop.visible_to?(drop, character_id, quest_status)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines drop ownership type based on damage contribution.
|
||||
"""
|
||||
@spec determine_drop_type([{integer(), integer()}], integer()) :: integer()
|
||||
def determine_drop_type(attackers, _killer_id) do
|
||||
# If only one attacker, owner-only
|
||||
# If multiple attackers, party/FFA based on damage distribution
|
||||
|
||||
case length(attackers) do
|
||||
1 -> @drop_type_owner_timeout
|
||||
_ -> @drop_type_party_timeout
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates drops with specific ownership rules.
|
||||
Used for special drops like event rewards.
|
||||
"""
|
||||
@spec create_special_drop(integer(), integer(), integer(), map(), integer(), keyword()) :: Drop.t()
|
||||
def create_special_drop(item_id, quantity, owner_id, position, oid, opts \\ []) do
|
||||
drop_type = Keyword.get(opts, :drop_type, @drop_type_explosive)
|
||||
quest_id = Keyword.get(opts, :quest_id, -1)
|
||||
individual = Keyword.get(opts, :individual_reward, false)
|
||||
|
||||
Drop.new_item_drop(oid, item_id, quantity, owner_id, position,
|
||||
drop_type: drop_type,
|
||||
quest_id: quest_id,
|
||||
individual_reward: individual
|
||||
)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Global Drop System (Global Drops apply to all monsters)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates global drops for a monster kill.
|
||||
These are additional drops that can drop from any monster.
|
||||
"""
|
||||
@spec create_global_drops(integer(), map(), integer(), float()) :: [Drop.t()]
|
||||
def create_global_drops(owner_id, position, next_oid, drop_rate_multiplier \\ @default_drop_rate) do
|
||||
global_entries = DropTable.get_global_drops()
|
||||
|
||||
{drops, _next_oid} =
|
||||
Enum.reduce(global_entries, {[], next_oid}, fn entry, {acc, oid} ->
|
||||
case DropTable.roll_drop(entry, drop_rate_multiplier) do
|
||||
nil ->
|
||||
{acc, oid}
|
||||
|
||||
{item_id, quantity} ->
|
||||
if ItemInfo.item_exists?(item_id) do
|
||||
drop_position = calculate_drop_position(position)
|
||||
|
||||
drop = Drop.new_item_drop(oid, item_id, quantity, owner_id, drop_position,
|
||||
drop_type: entry.drop_type,
|
||||
quest_id: entry.questid
|
||||
)
|
||||
|
||||
{[drop | acc], oid + 1}
|
||||
else
|
||||
{acc, oid}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.reverse(drops)
|
||||
end
|
||||
end
|
||||
321
lib/odinsea/game/drop_table.ex
Normal file
321
lib/odinsea/game/drop_table.ex
Normal file
@@ -0,0 +1,321 @@
|
||||
defmodule Odinsea.Game.DropTable do
|
||||
@moduledoc """
|
||||
Manages drop tables for monsters.
|
||||
Ported from Java server.life.MonsterDropEntry and MapleMonsterInformationProvider
|
||||
|
||||
Drop tables define what items a monster can drop when killed.
|
||||
Each drop entry includes:
|
||||
- item_id: The item to drop
|
||||
- chance: Drop rate (out of 1,000,000 for most items)
|
||||
- min_quantity: Minimum quantity
|
||||
- max_quantity: Maximum quantity
|
||||
- quest_id: Quest requirement (-1 = no quest)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.LifeFactory
|
||||
|
||||
require Logger
|
||||
|
||||
@typedoc "A single drop entry"
|
||||
@type drop_entry :: %{
|
||||
item_id: integer(),
|
||||
chance: integer(),
|
||||
min_quantity: integer(),
|
||||
max_quantity: integer(),
|
||||
quest_id: integer()
|
||||
}
|
||||
|
||||
@typedoc "Drop table for a monster"
|
||||
@type drop_table :: [drop_entry()]
|
||||
|
||||
# Default drop rates for different item categories
|
||||
@equip_drop_rate 0.05 # 5% base for equipment
|
||||
@use_drop_rate 0.10 # 10% for use items
|
||||
@etc_drop_rate 0.15 # 15% for etc items
|
||||
@setup_drop_rate 0.02 # 2% for setup items
|
||||
@cash_drop_rate 0.01 # 1% for cash items
|
||||
|
||||
# Meso drop configuration
|
||||
@meso_chance_normal 400_000 # 40% base for normal mobs
|
||||
@meso_chance_boss 1_000_000 # 100% for bosses
|
||||
|
||||
@doc """
|
||||
Gets the drop table for a monster.
|
||||
"""
|
||||
@spec get_drops(integer()) :: drop_table()
|
||||
def get_drops(mob_id) do
|
||||
# Try to get from cache first
|
||||
case lookup_drop_table(mob_id) do
|
||||
nil -> load_and_cache_drops(mob_id)
|
||||
drops -> drops
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates drops for a monster kill.
|
||||
Returns a list of {item_id, quantity} tuples or {:meso, amount}.
|
||||
"""
|
||||
@spec calculate_drops(integer(), integer(), map()) :: [{:item | :meso, integer(), integer()}]
|
||||
def calculate_drops(mob_id, drop_rate_multiplier, _opts \\ %{}) do
|
||||
drops = get_drops(mob_id)
|
||||
|
||||
# Get monster stats for meso calculation
|
||||
stats = LifeFactory.get_monster_stats(mob_id)
|
||||
|
||||
results =
|
||||
if stats do
|
||||
# Check if meso drops are disabled for this monster type
|
||||
should_drop_mesos = should_drop_mesos?(stats)
|
||||
|
||||
# Add meso drops if applicable
|
||||
meso_drops =
|
||||
if should_drop_mesos do
|
||||
calculate_meso_drops(stats, drop_rate_multiplier)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Roll for each item drop
|
||||
item_drops =
|
||||
Enum.flat_map(drops, fn entry ->
|
||||
case roll_drop(entry, drop_rate_multiplier) do
|
||||
nil -> []
|
||||
{item_id, quantity} -> [{:item, item_id, quantity}]
|
||||
end
|
||||
end)
|
||||
|
||||
meso_drops ++ item_drops
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Limit total drops to prevent flooding
|
||||
Enum.take(results, 10)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rolls for a single drop entry.
|
||||
Returns {item_id, quantity} if successful, nil if failed.
|
||||
"""
|
||||
@spec roll_drop(drop_entry(), integer() | float()) :: {integer(), integer()} | nil
|
||||
def roll_drop(entry, multiplier) do
|
||||
# Calculate adjusted chance
|
||||
base_chance = entry.chance
|
||||
adjusted_chance = trunc(base_chance * multiplier)
|
||||
|
||||
# Roll (1,000,000 = 100%)
|
||||
roll = :rand.uniform(1_000_000)
|
||||
|
||||
if roll <= adjusted_chance do
|
||||
# Determine quantity
|
||||
quantity =
|
||||
if entry.max_quantity > entry.min_quantity do
|
||||
entry.min_quantity + :rand.uniform(entry.max_quantity - entry.min_quantity + 1) - 1
|
||||
else
|
||||
entry.min_quantity
|
||||
end
|
||||
|
||||
{entry.item_id, max(1, quantity)}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates meso drops for a monster.
|
||||
"""
|
||||
@spec calculate_meso_drops(map(), integer() | float()) :: [{:meso, integer(), integer()}]
|
||||
def calculate_meso_drops(stats, drop_rate_multiplier) do
|
||||
# Determine number of meso drops based on monster type
|
||||
num_drops =
|
||||
cond do
|
||||
stats.boss and not stats.party_bonus -> 2
|
||||
stats.party_bonus -> 1
|
||||
true -> 1
|
||||
end
|
||||
|
||||
# Calculate max meso amount
|
||||
level = stats.level
|
||||
|
||||
# Formula: level * (level / 10) = max
|
||||
# Min = 0.66 * max
|
||||
divided = if level < 100, do: max(level, 10) / 10.0, else: level / 10.0
|
||||
|
||||
max_amount =
|
||||
if stats.boss and not stats.party_bonus do
|
||||
level * level
|
||||
else
|
||||
trunc(level * :math.ceil(level / divided))
|
||||
end
|
||||
|
||||
min_amount = trunc(0.66 * max_amount)
|
||||
|
||||
# Roll for each meso drop
|
||||
base_chance = if stats.boss and not stats.party_bonus, do: @meso_chance_boss, else: @meso_chance_normal
|
||||
adjusted_chance = trunc(base_chance * drop_rate_multiplier)
|
||||
|
||||
Enum.flat_map(1..num_drops, fn _ ->
|
||||
roll = :rand.uniform(1_000_000)
|
||||
|
||||
if roll <= adjusted_chance do
|
||||
amount = min_amount + :rand.uniform(max(1, max_amount - min_amount + 1)) - 1
|
||||
[{:meso, max(1, amount), 1}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets global drops that apply to all monsters.
|
||||
"""
|
||||
@spec get_global_drops() :: drop_table()
|
||||
def get_global_drops do
|
||||
# Global drops from database (would be loaded from drop_data_global table)
|
||||
# For now, return empty list
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears the drop table cache.
|
||||
"""
|
||||
def clear_cache do
|
||||
:ets.delete_all_objects(:drop_table_cache)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp lookup_drop_table(mob_id) do
|
||||
case :ets.lookup(:drop_table_cache, mob_id) do
|
||||
[{^mob_id, drops}] -> drops
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp load_and_cache_drops(mob_id) do
|
||||
drops = load_drops_from_source(mob_id)
|
||||
:ets.insert(:drop_table_cache, {mob_id, drops})
|
||||
drops
|
||||
end
|
||||
|
||||
defp load_drops_from_source(mob_id) do
|
||||
# In a full implementation, this would:
|
||||
# 1. Query drop_data_final_v2 table
|
||||
# 2. Apply chance adjustments based on item type
|
||||
# 3. Return processed drops
|
||||
|
||||
# For now, return fallback drops based on monster level
|
||||
generate_fallback_drops(mob_id)
|
||||
end
|
||||
|
||||
defp generate_fallback_drops(mob_id) do
|
||||
# Get monster stats to determine level-appropriate drops
|
||||
case LifeFactory.get_monster_stats(mob_id) do
|
||||
nil -> []
|
||||
stats -> generate_level_drops(stats)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_level_drops(stats) do
|
||||
level = stats.level
|
||||
|
||||
# Generate appropriate drops based on monster level
|
||||
cond do
|
||||
level <= 10 ->
|
||||
# Beginner drops
|
||||
[
|
||||
%{item_id: 2000000, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Red Potion
|
||||
%{item_id: 2000001, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Orange Potion
|
||||
%{item_id: 4000000, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Snail Shell
|
||||
%{item_id: 4000001, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Blue Snail Shell
|
||||
]
|
||||
|
||||
level <= 20 ->
|
||||
# Low level drops
|
||||
[
|
||||
%{item_id: 2000002, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # White Potion
|
||||
%{item_id: 2000003, chance: 50_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Blue Potion
|
||||
%{item_id: 4000002, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Red Snail Shell
|
||||
%{item_id: 4000010, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Mushroom Spores
|
||||
]
|
||||
|
||||
level <= 40 ->
|
||||
# Mid level drops
|
||||
[
|
||||
%{item_id: 2000004, chance: 40_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Elixir
|
||||
%{item_id: 2000005, chance: 40_000, min_quantity: 1, max_quantity: 3, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 4000011, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Pig's Head
|
||||
%{item_id: 4000012, chance: 30_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Pig's Ribbon
|
||||
]
|
||||
|
||||
level <= 70 ->
|
||||
# Higher level drops
|
||||
[
|
||||
%{item_id: 2000005, chance: 50_000, min_quantity: 1, max_quantity: 5, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 2040000, chance: 10_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Helmet
|
||||
%{item_id: 2040800, chance: 10_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Gloves
|
||||
]
|
||||
|
||||
true ->
|
||||
# High level drops
|
||||
[
|
||||
%{item_id: 2000005, chance: 60_000, min_quantity: 1, max_quantity: 10, quest_id: -1}, # Power Elixir
|
||||
%{item_id: 2044000, chance: 5_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Two-Handed Sword
|
||||
%{item_id: 2044100, chance: 5_000, min_quantity: 1, max_quantity: 1, quest_id: -1}, # Scroll for Two-Handed Axe
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
defp should_drop_mesos?(stats) do
|
||||
# Don't drop mesos if:
|
||||
# - Monster has special properties (invincible, friendly, etc.)
|
||||
# - Monster is a fixed damage mob
|
||||
# - Monster is a special event mob
|
||||
|
||||
cond do
|
||||
stats.invincible -> false
|
||||
stats.friendly -> false
|
||||
stats.fixed_damage > 0 -> false
|
||||
stats.remove_after > 0 -> false
|
||||
true -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Initializes the drop table cache ETS table.
|
||||
Called during application startup.
|
||||
"""
|
||||
def init_cache do
|
||||
:ets.new(:drop_table_cache, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
Logger.info("Drop table cache initialized")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Implementation (for supervision)
|
||||
# ============================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
@doc """
|
||||
Starts the DropTable cache manager.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
init_cache()
|
||||
{:ok, %{}}
|
||||
end
|
||||
end
|
||||
444
lib/odinsea/game/event.ex
Normal file
444
lib/odinsea/game/event.ex
Normal file
@@ -0,0 +1,444 @@
|
||||
defmodule Odinsea.Game.Event do
|
||||
@moduledoc """
|
||||
Base behaviour and common functions for in-game events.
|
||||
Ported from Java `server.events.MapleEvent`.
|
||||
|
||||
Events are scheduled activities that players can participate in for rewards.
|
||||
Each event type has specific gameplay mechanics, maps, and win conditions.
|
||||
|
||||
## Event Lifecycle
|
||||
1. Schedule - Event is scheduled on a channel
|
||||
2. Registration - Players register/join the event
|
||||
3. Start - Event begins with gameplay
|
||||
4. Gameplay - Event-specific mechanics run
|
||||
5. Finish - Winners receive prizes, all players warped out
|
||||
6. Reset - Event state is cleared for next run
|
||||
|
||||
## Implemented Events
|
||||
- Coconut - Team-based coconut hitting competition
|
||||
- Fitness - Obstacle course race (4 stages)
|
||||
- OlaOla - Portal guessing game (3 stages)
|
||||
- OxQuiz - True/False quiz with position-based answers
|
||||
- Snowball - Team snowball rolling competition
|
||||
- Survival - Last-man-standing platform challenge
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Event type identifier"
|
||||
@type event_type :: :coconut | :coke_play | :fitness | :ola_ola | :ox_quiz | :survival | :snowball
|
||||
|
||||
@typedoc "Event state struct"
|
||||
@type t :: %__MODULE__{
|
||||
type: event_type(),
|
||||
channel_id: non_neg_integer(),
|
||||
map_ids: [non_neg_integer()],
|
||||
is_running: boolean(),
|
||||
player_count: non_neg_integer(),
|
||||
registered_players: MapSet.t(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:type,
|
||||
:channel_id,
|
||||
:map_ids,
|
||||
is_running: false,
|
||||
player_count: 0,
|
||||
registered_players: MapSet.new(),
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Behaviour Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a player finishes the event (reaches end map).
|
||||
Override to implement finish logic (give prizes, achievements, etc.)
|
||||
"""
|
||||
@callback finished(t(), Character.t()) :: :ok
|
||||
|
||||
@doc """
|
||||
Called to start the event gameplay.
|
||||
Override to implement event start logic (timers, broadcasts, etc.)
|
||||
"""
|
||||
@callback start_event(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Override to send event-specific packets (clock, instructions, etc.)
|
||||
Default implementation sends event instructions.
|
||||
"""
|
||||
@callback on_map_load(t(), Character.t()) :: :ok
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new run.
|
||||
Override to reset event-specific state (scores, stages, etc.)
|
||||
"""
|
||||
@callback reset(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
Override to cancel timers and reset state.
|
||||
"""
|
||||
@callback unreset(t()) :: t()
|
||||
|
||||
@doc """
|
||||
Returns the map IDs associated with this event type.
|
||||
"""
|
||||
@callback map_ids() :: [non_neg_integer()]
|
||||
|
||||
# ============================================================================
|
||||
# Behaviour Definition
|
||||
# ============================================================================
|
||||
|
||||
@optional_callbacks [on_map_load: 2]
|
||||
|
||||
# ============================================================================
|
||||
# Common Functions
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new event struct.
|
||||
"""
|
||||
def new(type, channel_id, map_ids) do
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
channel_id: channel_id,
|
||||
map_ids: map_ids,
|
||||
is_running: false,
|
||||
player_count: 0,
|
||||
registered_players: MapSet.new(),
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increments the player count. If count reaches 250, automatically starts the event.
|
||||
Returns updated event state.
|
||||
"""
|
||||
def increment_player_count(%__MODULE__{} = event) do
|
||||
new_count = event.player_count + 1
|
||||
event = %{event | player_count: new_count}
|
||||
|
||||
if new_count == 250 do
|
||||
Logger.info("Event #{event.type} reached 250 players, auto-starting...")
|
||||
set_event_auto_start(event.channel_id)
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a player for the event.
|
||||
"""
|
||||
def register_player(%__MODULE__{} = event, character_id) do
|
||||
%{event | registered_players: MapSet.put(event.registered_players, character_id)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from the event.
|
||||
"""
|
||||
def unregister_player(%__MODULE__{} = event, character_id) do
|
||||
%{event | registered_players: MapSet.delete(event.registered_players, character_id)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is registered for the event.
|
||||
"""
|
||||
def registered?(%__MODULE__{} = event, character_id) do
|
||||
MapSet.member?(event.registered_players, character_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the first map ID (entry map) for the event.
|
||||
"""
|
||||
def entry_map_id(%__MODULE__{map_ids: [first | _]}), do: first
|
||||
def entry_map_id(%__MODULE__{map_ids: []}), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if the given map ID is part of this event.
|
||||
"""
|
||||
def event_map?(%__MODULE__{} = event, map_id) do
|
||||
map_id in event.map_ids
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the map index for the given map ID (0-based).
|
||||
Returns nil if map is not part of this event.
|
||||
"""
|
||||
def map_index(%__MODULE__{} = event, map_id) do
|
||||
case Enum.find_index(event.map_ids, &(&1 == map_id)) do
|
||||
nil -> nil
|
||||
index -> index
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Default implementation for on_map_load callback.
|
||||
Sends event instructions to the player if they're on an event map.
|
||||
"""
|
||||
def on_map_load_default(_event, character) do
|
||||
# Send event instructions packet
|
||||
# This would typically show instructions UI
|
||||
# For now, just log
|
||||
Logger.debug("Player #{character.name} loaded event map")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps a character back to their saved location or default town.
|
||||
"""
|
||||
def warp_back(character) do
|
||||
# Get saved location or use default (Henesys: 104000000)
|
||||
return_map = character.saved_location || 104000000
|
||||
|
||||
# This would typically call Character.change_map/2
|
||||
# For now, just log
|
||||
Logger.info("Warping player #{character.name} back to map #{return_map}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives a random event prize to a character.
|
||||
Prizes include: mesos, cash, vote points, fame, or items.
|
||||
"""
|
||||
def give_prize(character) do
|
||||
reward_type = random_reward_type()
|
||||
|
||||
case reward_type do
|
||||
:meso ->
|
||||
amount = :rand.uniform(9_000_000) + 1_000_000
|
||||
# Character.gain_meso(character, amount)
|
||||
Logger.info("Event prize: #{character.name} gained #{amount} mesos")
|
||||
|
||||
:cash ->
|
||||
amount = :rand.uniform(4000) + 1000
|
||||
# Character.modify_cash_points(character, amount)
|
||||
Logger.info("Event prize: #{character.name} gained #{amount} NX")
|
||||
|
||||
:vote_points ->
|
||||
# Character.add_vote_points(character, 1)
|
||||
Logger.info("Event prize: #{character.name} gained 1 vote point")
|
||||
|
||||
:fame ->
|
||||
# Character.add_fame(character, 10)
|
||||
Logger.info("Event prize: #{character.name} gained 10 fame")
|
||||
|
||||
:none ->
|
||||
Logger.info("Event prize: #{character.name} got no reward")
|
||||
|
||||
{:item, item_id, quantity} ->
|
||||
# Check inventory space and add item
|
||||
Logger.info("Event prize: #{character.name} got item #{item_id} x#{quantity}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Random reward weights
|
||||
defp random_reward_type do
|
||||
roll = :rand.uniform(100)
|
||||
|
||||
cond do
|
||||
roll <= 25 -> :meso # 25% mesos
|
||||
roll <= 50 -> :cash # 25% cash
|
||||
roll <= 60 -> :vote_points # 10% vote points
|
||||
roll <= 70 -> :fame # 10% fame
|
||||
roll <= 75 -> :none # 5% no reward
|
||||
true -> random_item_reward() # 25% items
|
||||
end
|
||||
end
|
||||
|
||||
defp random_item_reward do
|
||||
# Item pool with quantities
|
||||
items = [
|
||||
{5062000, 1..3}, # Premium Miracle Cube (1-3)
|
||||
{5220000, 1..25}, # Gachapon Ticket (1-25)
|
||||
{4031307, 1..5}, # Piece of Statue (1-5)
|
||||
{5050000, 1..5}, # AP Reset Scroll (1-5)
|
||||
{2022121, 1..10}, # Chewy Rice Cake (1-10)
|
||||
]
|
||||
|
||||
{item_id, qty_range} = Enum.random(items)
|
||||
quantity = Enum.random(qty_range)
|
||||
|
||||
{:item, item_id, quantity}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules the event to auto-start after player count threshold.
|
||||
"""
|
||||
def set_event_auto_start(channel_id) do
|
||||
# Schedule 30 second countdown before start
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
broadcast_to_channel(channel_id, "The event will start in 30 seconds!")
|
||||
# Start clock countdown
|
||||
EventTimer.schedule(
|
||||
fn -> start_scheduled_event(channel_id) end,
|
||||
30_000
|
||||
)
|
||||
end,
|
||||
0
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a server notice to all players on a channel.
|
||||
"""
|
||||
def broadcast_to_channel(channel_id, message) do
|
||||
# This would call ChannelServer.broadcast
|
||||
Logger.info("[Channel #{channel_id}] Broadcast: #{message}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all players in all event maps.
|
||||
"""
|
||||
def broadcast_to_event(%__MODULE__{} = event, _packet) do
|
||||
# This would broadcast to all maps in event.map_ids
|
||||
Logger.debug("Broadcasting to event #{event.type} on channel #{event.channel_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles when a player loads into any map.
|
||||
Checks if they're on an event map and calls appropriate callbacks.
|
||||
"""
|
||||
def on_map_load(events, character, map_id, channel_id) do
|
||||
Enum.each(events, fn {event_type, event} ->
|
||||
if event.channel_id == channel_id and event.is_running do
|
||||
if map_id == 109050000 do
|
||||
# Finished map - call finished callback
|
||||
apply(event_module(event_type), :finished, [event, character])
|
||||
end
|
||||
|
||||
if event_map?(event, map_id) do
|
||||
# Event map - call on_map_load callback
|
||||
if function_exported?(event_module(event_type), :on_map_load, 2) do
|
||||
apply(event_module(event_type), :on_map_load, [event, character])
|
||||
else
|
||||
on_map_load_default(event, character)
|
||||
end
|
||||
|
||||
# If first map, increment player count
|
||||
if map_index(event, map_id) == 0 do
|
||||
increment_player_count(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles manual event start command from a GM.
|
||||
"""
|
||||
def on_start_event(events, character, map_id) do
|
||||
Enum.each(events, fn {event_type, event} ->
|
||||
if event.is_running and event_map?(event, map_id) do
|
||||
new_event = apply(event_module(event_type), :start_event, [event])
|
||||
set_event(character.channel_id, -1)
|
||||
Logger.info("GM #{character.name} started event #{event_type}")
|
||||
new_event
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules an event to run on a channel.
|
||||
Returns {:ok, updated_events} or {:error, reason}.
|
||||
"""
|
||||
def schedule_event(events, event_type, channel_id) do
|
||||
event = Map.get(events, event_type)
|
||||
|
||||
cond do
|
||||
is_nil(event) ->
|
||||
{:error, "Event type not found"}
|
||||
|
||||
event.is_running ->
|
||||
{:error, "The event is already running."}
|
||||
|
||||
true ->
|
||||
# Check if maps have players (simplified check)
|
||||
# In real implementation, check all map_ids
|
||||
entry_map = entry_map_id(event)
|
||||
|
||||
# Reset and activate event
|
||||
event = apply(event_module(event_type), :reset, [event])
|
||||
event = %{event | is_running: true}
|
||||
|
||||
# Broadcast to channel
|
||||
event_name = humanize_event_name(event_type)
|
||||
broadcast_to_channel(
|
||||
channel_id,
|
||||
"Hello! Let's play a #{event_name} event in channel #{channel_id}! " <>
|
||||
"Change to channel #{channel_id} and use @event command!"
|
||||
)
|
||||
|
||||
{:ok, Map.put(events, event_type, event)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the channel's active event map.
|
||||
"""
|
||||
def set_event(channel_id, map_id) do
|
||||
# This would update ChannelServer state
|
||||
Logger.debug("Set channel #{channel_id} event map to #{map_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels all scheduled timers for an event.
|
||||
"""
|
||||
def cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a schedule reference to the event.
|
||||
"""
|
||||
def add_schedule(%__MODULE__{} = event, schedule_ref) do
|
||||
%{event | schedules: [schedule_ref | event.schedules]}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp start_scheduled_event(_channel_id) do
|
||||
# Find the scheduled event and start it
|
||||
Logger.info("Auto-starting scheduled event")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp event_module(:coconut), do: Odinsea.Game.Events.Coconut
|
||||
defp event_module(:fitness), do: Odinsea.Game.Events.Fitness
|
||||
defp event_module(:ola_ola), do: Odinsea.Game.Events.OlaOla
|
||||
defp event_module(:ox_quiz), do: Odinsea.Game.Events.OxQuiz
|
||||
defp event_module(:snowball), do: Odinsea.Game.Events.Snowball
|
||||
defp event_module(:survival), do: Odinsea.Game.Events.Survival
|
||||
defp event_module(_), do: nil
|
||||
|
||||
defp humanize_event_name(type) do
|
||||
type
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
end
|
||||
606
lib/odinsea/game/event_manager.ex
Normal file
606
lib/odinsea/game/event_manager.ex
Normal file
@@ -0,0 +1,606 @@
|
||||
defmodule Odinsea.Game.EventManager do
|
||||
@moduledoc """
|
||||
Event Manager for scheduling and managing in-game events.
|
||||
Ported from Java `server.events` scheduling functionality.
|
||||
|
||||
## Responsibilities
|
||||
- Event scheduling per channel
|
||||
- Player registration for events
|
||||
- Event state management
|
||||
- Event coordination across channels
|
||||
|
||||
## Event Types
|
||||
- Coconut - Team coconut hitting
|
||||
- Fitness - Obstacle course
|
||||
- OlaOla - Portal guessing
|
||||
- OxQuiz - True/False quiz
|
||||
- Snowball - Team snowball rolling
|
||||
- Survival - Last man standing
|
||||
|
||||
## Usage
|
||||
Event scheduling is typically done by GM commands or automated system:
|
||||
|
||||
# Schedule an event
|
||||
EventManager.schedule_event(channel_id, :coconut)
|
||||
|
||||
# Player joins event
|
||||
EventManager.join_event(channel_id, character_id, :coconut)
|
||||
|
||||
# Start the event
|
||||
EventManager.start_event(channel_id, :coconut)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
alias Odinsea.Game.Events
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Channel event state"
|
||||
@type channel_events :: %{
|
||||
optional(Events.t()) => Event.t() | struct()
|
||||
}
|
||||
|
||||
@typedoc "Manager state"
|
||||
@type state :: %{
|
||||
channels: %{optional(non_neg_integer()) => channel_events()},
|
||||
schedules: %{optional(reference()) => {:auto_start, non_neg_integer(), Events.t()}}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the Event Manager.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules an event to run on a specific channel.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel to run event on
|
||||
- event_type: Type of event (:coconut, :fitness, etc.)
|
||||
|
||||
## Returns
|
||||
- :ok on success
|
||||
- {:error, reason} on failure
|
||||
"""
|
||||
def schedule_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:schedule_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a scheduled event immediately.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel running the event
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def start_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:start_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a scheduled event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def cancel_event(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:cancel_event, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a player for an event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- character_id: Character joining
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def join_event(channel_id, character_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:join_event, channel_id, character_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from an event.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel with the event
|
||||
- character_id: Character leaving
|
||||
- event_type: Type of event
|
||||
"""
|
||||
def leave_event(channel_id, character_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:leave_event, channel_id, character_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player loading into an event map.
|
||||
Called by map load handlers.
|
||||
|
||||
## Parameters
|
||||
- channel_id: Channel the player is on
|
||||
- character: Character struct
|
||||
- map_id: Map ID player loaded into
|
||||
"""
|
||||
def on_map_load(channel_id, character, map_id) do
|
||||
GenServer.cast(__MODULE__, {:on_map_load, channel_id, character, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a GM manually starting an event.
|
||||
"""
|
||||
def on_start_event(channel_id, character, map_id) do
|
||||
GenServer.cast(__MODULE__, {:on_start_event, channel_id, character, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the active event on a channel.
|
||||
"""
|
||||
def get_active_event(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_active_event, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all events for a channel.
|
||||
"""
|
||||
def get_channel_events(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_channel_events, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an event is running on a channel.
|
||||
"""
|
||||
def event_running?(channel_id, event_type) do
|
||||
GenServer.call(__MODULE__, {:event_running?, channel_id, event_type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the active event map for a channel.
|
||||
This is the map where players should go to join.
|
||||
"""
|
||||
def set_event_map(channel_id, map_id) do
|
||||
GenServer.cast(__MODULE__, {:set_event_map, channel_id, map_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the event map for a channel (where players join).
|
||||
"""
|
||||
def get_event_map(channel_id) do
|
||||
GenServer.call(__MODULE__, {:get_event_map, channel_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available event types.
|
||||
"""
|
||||
def list_event_types do
|
||||
Events.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets event info for a type.
|
||||
"""
|
||||
def event_info(event_type) do
|
||||
%{
|
||||
type: event_type,
|
||||
name: Events.display_name(event_type),
|
||||
map_ids: Events.map_ids(event_type),
|
||||
entry_map: Events.entry_map_id(event_type),
|
||||
stages: Events.stage_count(event_type),
|
||||
is_race: Events.race_event?(event_type),
|
||||
is_team: Events.team_event?(event_type)
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Logger.info("EventManager started")
|
||||
|
||||
state = %{
|
||||
channels: %{},
|
||||
schedules: %{},
|
||||
event_maps: %{} # channel_id => map_id
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule_event, channel_id, event_type}, _from, state) do
|
||||
case do_schedule_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
broadcast_event_notice(channel_id, event_type)
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:start_event, channel_id, event_type}, _from, state) do
|
||||
case do_start_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
{:reply, :ok, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel_event, channel_id, event_type}, _from, state) do
|
||||
new_state = do_cancel_event(state, channel_id, event_type)
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:join_event, channel_id, character_id, event_type}, _from, state) do
|
||||
{reply, new_state} = do_join_event(state, channel_id, character_id, event_type)
|
||||
{:reply, reply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:leave_event, channel_id, character_id, event_type}, _from, state) do
|
||||
{reply, new_state} = do_leave_event(state, channel_id, character_id, event_type)
|
||||
{:reply, reply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_active_event, channel_id}, _from, state) do
|
||||
event = get_active_event_impl(state, channel_id)
|
||||
{:reply, event, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_channel_events, channel_id}, _from, state) do
|
||||
events = Map.get(state.channels, channel_id, %{})
|
||||
{:reply, events, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:event_running?, channel_id, event_type}, _from, state) do
|
||||
running = event_running_impl?(state, channel_id, event_type)
|
||||
{:reply, running, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_event_map, channel_id}, _from, state) do
|
||||
map_id = Map.get(state.event_maps, channel_id)
|
||||
{:reply, map_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:on_map_load, channel_id, character, map_id}, state) do
|
||||
new_state = do_on_map_load(state, channel_id, character, map_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:on_start_event, channel_id, character, map_id}, state) do
|
||||
new_state = do_on_start_event(state, channel_id, character, map_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:set_event_map, channel_id, map_id}, state) do
|
||||
new_maps = Map.put(state.event_maps, channel_id, map_id)
|
||||
{:noreply, %{state | event_maps: new_maps}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:auto_start, channel_id, event_type}, state) do
|
||||
Logger.info("Auto-starting event #{event_type} on channel #{channel_id}")
|
||||
|
||||
# Start the event
|
||||
case do_start_event(state, channel_id, event_type) do
|
||||
{:ok, new_state} ->
|
||||
# Clear event map
|
||||
new_maps = Map.delete(new_state.event_maps, channel_id)
|
||||
{:noreply, %{new_state | event_maps: new_maps}}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_schedule_event(state, channel_id, event_type) do
|
||||
# Check if event type is valid
|
||||
if event_type not in Events.all() do
|
||||
{:error, "Invalid event type"}
|
||||
else
|
||||
# Check if event is already running
|
||||
if event_running_impl?(state, channel_id, event_type) do
|
||||
{:error, "Event already running"}
|
||||
else
|
||||
# Create event instance
|
||||
event = create_event(event_type, channel_id)
|
||||
|
||||
# Reset event
|
||||
event = reset_event(event, event_type)
|
||||
|
||||
# Store event
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Set event map (entry map)
|
||||
entry_map = Events.entry_map_id(event_type)
|
||||
event_maps = Map.put(state.event_maps, channel_id, entry_map)
|
||||
|
||||
{:ok, %{state | channels: channels, event_maps: event_maps}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_start_event(state, channel_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Start the event
|
||||
new_event = start_event_impl(event, event_type)
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
{:ok, %{state | channels: channels, event_maps: event_maps}}
|
||||
else
|
||||
nil -> {:error, "Event not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_cancel_event(state, channel_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Unreset event (cleanup)
|
||||
unreset_event(event, event_type)
|
||||
|
||||
# Remove from state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.delete(channel_events, event_type)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
%{state | channels: channels, event_maps: event_maps}
|
||||
else
|
||||
nil -> state
|
||||
end
|
||||
end
|
||||
|
||||
defp do_join_event(state, channel_id, character_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
if event.base.is_running do
|
||||
# Register player
|
||||
new_event = Event.register_player(event.base, character_id)
|
||||
new_event = %{event | base: new_event}
|
||||
|
||||
# Check if we should auto-start (250 players)
|
||||
new_event = Event.increment_player_count(new_event.base)
|
||||
new_event = %{event | base: new_event}
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
{{:ok, :joined}, %{state | channels: channels}}
|
||||
else
|
||||
{{:error, "Event not running"}, state}
|
||||
end
|
||||
else
|
||||
nil -> {{:error, "Event not found"}, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_leave_event(state, channel_id, character_id, event_type) do
|
||||
with {:ok, event} <- get_event(state, channel_id, event_type) do
|
||||
# Unregister player
|
||||
new_base = Event.unregister_player(event.base, character_id)
|
||||
new_event = %{event | base: new_base}
|
||||
|
||||
# Update state
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
{{:ok, :left}, %{state | channels: channels}}
|
||||
else
|
||||
nil -> {{:error, "Event not found"}, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_on_map_load(state, channel_id, character, map_id) do
|
||||
# Check if any event is running on this channel
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.reduce(channel_events, state, fn {event_type, event}, acc_state ->
|
||||
if event.base.is_running do
|
||||
# Check if this is the finish map
|
||||
if map_id == 109050000 do
|
||||
# Call finished callback
|
||||
finished_event(event, event_type, character)
|
||||
end
|
||||
|
||||
# Check if this is an event map
|
||||
if Event.event_map?(event.base, map_id) do
|
||||
# Call on_map_load callback
|
||||
on_map_load_event(event, event_type, character)
|
||||
|
||||
# If first map, increment player count
|
||||
if Event.map_index(event.base, map_id) == 0 do
|
||||
new_base = Event.increment_player_count(event.base)
|
||||
|
||||
# Check if we hit 250 players
|
||||
if new_base.player_count >= 250 do
|
||||
# Auto-start
|
||||
schedule_auto_start(channel_id, event_type)
|
||||
end
|
||||
|
||||
# Update event in state
|
||||
new_event = put_event_base(event, event_type, new_base)
|
||||
channel_events = Map.put(acc_state.channels[channel_id], event_type, new_event)
|
||||
channels = Map.put(acc_state.channels, channel_id, channel_events)
|
||||
%{acc_state | channels: channels}
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
else
|
||||
acc_state
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_on_start_event(state, channel_id, character, map_id) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.find_value(channel_events, state, fn {event_type, event} ->
|
||||
if event.base.is_running and Event.event_map?(event.base, map_id) do
|
||||
# Start the event
|
||||
new_event = start_event_impl(event, event_type)
|
||||
|
||||
# Update state
|
||||
channel_events = Map.put(channel_events, event_type, new_event)
|
||||
channels = Map.put(state.channels, channel_id, channel_events)
|
||||
|
||||
# Clear event map
|
||||
event_maps = Map.delete(state.event_maps, channel_id)
|
||||
|
||||
%{state | channels: channels, event_maps: event_maps}
|
||||
end
|
||||
end) || state
|
||||
end
|
||||
|
||||
defp get_event(state, channel_id, event_type) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
case Map.get(channel_events, event_type) do
|
||||
nil -> nil
|
||||
event -> {:ok, event}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_active_event_impl(state, channel_id) do
|
||||
channel_events = Map.get(state.channels, channel_id, %{})
|
||||
|
||||
Enum.find_value(channel_events, fn {event_type, event} ->
|
||||
if event.base.is_running do
|
||||
{event_type, event}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp event_running_impl?(state, channel_id, event_type) do
|
||||
case get_event(state, channel_id, event_type) do
|
||||
{:ok, event} -> event.base.is_running
|
||||
nil -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp create_event(:coconut, channel_id), do: Odinsea.Game.Events.Coconut.new(channel_id)
|
||||
defp create_event(:fitness, channel_id), do: Odinsea.Game.Events.Fitness.new(channel_id)
|
||||
defp create_event(:ola_ola, channel_id), do: Odinsea.Game.Events.OlaOla.new(channel_id)
|
||||
defp create_event(:ox_quiz, channel_id), do: Odinsea.Game.Events.OxQuiz.new(channel_id)
|
||||
defp create_event(:snowball, channel_id), do: Odinsea.Game.Events.Snowball.new(channel_id)
|
||||
defp create_event(:survival, channel_id), do: Odinsea.Game.Events.Survival.new(channel_id)
|
||||
defp create_event(_, _), do: nil
|
||||
|
||||
defp reset_event(event, :coconut), do: Odinsea.Game.Events.Coconut.reset(event)
|
||||
defp reset_event(event, :fitness), do: Odinsea.Game.Events.Fitness.reset(event)
|
||||
defp reset_event(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.reset(event)
|
||||
defp reset_event(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.reset(event)
|
||||
defp reset_event(event, :snowball), do: Odinsea.Game.Events.Snowball.reset(event)
|
||||
defp reset_event(event, :survival), do: Odinsea.Game.Events.Survival.reset(event)
|
||||
defp reset_event(event, _), do: event
|
||||
|
||||
defp unreset_event(event, :coconut), do: Odinsea.Game.Events.Coconut.unreset(event)
|
||||
defp unreset_event(event, :fitness), do: Odinsea.Game.Events.Fitness.unreset(event)
|
||||
defp unreset_event(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.unreset(event)
|
||||
defp unreset_event(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.unreset(event)
|
||||
defp unreset_event(event, :snowball), do: Odinsea.Game.Events.Snowball.unreset(event)
|
||||
defp unreset_event(event, :survival), do: Odinsea.Game.Events.Survival.unreset(event)
|
||||
defp unreset_event(event, _), do: event
|
||||
|
||||
defp start_event_impl(event, :coconut), do: Odinsea.Game.Events.Coconut.start_event(event)
|
||||
defp start_event_impl(event, :fitness), do: Odinsea.Game.Events.Fitness.start_event(event)
|
||||
defp start_event_impl(event, :ola_ola), do: Odinsea.Game.Events.OlaOla.start_event(event)
|
||||
defp start_event_impl(event, :ox_quiz), do: Odinsea.Game.Events.OxQuiz.start_event(event)
|
||||
defp start_event_impl(event, :snowball), do: Odinsea.Game.Events.Snowball.start_event(event)
|
||||
defp start_event_impl(event, :survival), do: Odinsea.Game.Events.Survival.start_event(event)
|
||||
defp start_event_impl(event, _), do: event
|
||||
|
||||
defp finished_event(event, :coconut, character), do: Odinsea.Game.Events.Coconut.finished(event, character)
|
||||
defp finished_event(event, :fitness, character), do: Odinsea.Game.Events.Fitness.finished(event, character)
|
||||
defp finished_event(event, :ola_ola, character), do: Odinsea.Game.Events.OlaOla.finished(event, character)
|
||||
defp finished_event(event, :ox_quiz, character), do: Odinsea.Game.Events.OxQuiz.finished(event, character)
|
||||
defp finished_event(event, :snowball, character), do: Odinsea.Game.Events.Snowball.finished(event, character)
|
||||
defp finished_event(event, :survival, character), do: Odinsea.Game.Events.Survival.finished(event, character)
|
||||
defp finished_event(_, _, _), do: :ok
|
||||
|
||||
defp on_map_load_event(event, :coconut, character), do: Odinsea.Game.Events.Coconut.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :fitness, character), do: Odinsea.Game.Events.Fitness.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :ola_ola, character), do: Odinsea.Game.Events.OlaOla.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :ox_quiz, character), do: Odinsea.Game.Events.OxQuiz.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :snowball, character), do: Odinsea.Game.Events.Snowball.on_map_load(event, character)
|
||||
defp on_map_load_event(event, :survival, character), do: Odinsea.Game.Events.Survival.on_map_load(event, character)
|
||||
defp on_map_load_event(_, _, _), do: :ok
|
||||
|
||||
defp put_event_base(event, :coconut, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :fitness, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :ola_ola, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :ox_quiz, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :snowball, base), do: %{event | base: base}
|
||||
defp put_event_base(event, :survival, base), do: %{event | base: base}
|
||||
defp put_event_base(event, _, _), do: event
|
||||
|
||||
defp schedule_auto_start(channel_id, event_type) do
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
send(__MODULE__, {:auto_start, channel_id, event_type})
|
||||
end,
|
||||
30_000 # 30 seconds
|
||||
)
|
||||
|
||||
broadcast_server_notice(channel_id, "The event will start in 30 seconds!")
|
||||
end
|
||||
|
||||
defp broadcast_event_notice(channel_id, event_type) do
|
||||
event_name = Events.display_name(event_type)
|
||||
|
||||
broadcast_server_notice(
|
||||
channel_id,
|
||||
"Hello! Let's play a #{event_name} event in channel #{channel_id}! " <>
|
||||
"Change to channel #{channel_id} and use @event command!"
|
||||
)
|
||||
end
|
||||
|
||||
defp broadcast_server_notice(channel_id, message) do
|
||||
# In real implementation, broadcast to channel
|
||||
Logger.info("[Channel #{channel_id}] #{message}")
|
||||
end
|
||||
end
|
||||
178
lib/odinsea/game/events.ex
Normal file
178
lib/odinsea/game/events.ex
Normal file
@@ -0,0 +1,178 @@
|
||||
defmodule Odinsea.Game.Events do
|
||||
@moduledoc """
|
||||
Event type definitions and map IDs.
|
||||
Ported from Java `server.events.MapleEventType`.
|
||||
|
||||
Each event type has associated map IDs where the event takes place.
|
||||
"""
|
||||
|
||||
@typedoc "Event type atom"
|
||||
@type t :: :coconut | :coke_play | :fitness | :ola_ola | :ox_quiz | :survival | :snowball
|
||||
|
||||
# ============================================================================
|
||||
# Event Map IDs
|
||||
# ============================================================================
|
||||
|
||||
@event_maps %{
|
||||
# Coconut event - team-based coconut hitting
|
||||
coconut: [109080000],
|
||||
|
||||
# Coke Play event (similar to coconut)
|
||||
coke_play: [109080010],
|
||||
|
||||
# Fitness event - 4 stage obstacle course
|
||||
fitness: [109040000, 109040001, 109040002, 109040003, 109040004],
|
||||
|
||||
# Ola Ola event - 3 stage portal guessing game
|
||||
ola_ola: [109030001, 109030002, 109030003],
|
||||
|
||||
# OX Quiz event - True/False quiz
|
||||
ox_quiz: [109020001],
|
||||
|
||||
# Survival event - Last man standing (2 maps)
|
||||
survival: [809040000, 809040100],
|
||||
|
||||
# Snowball event - Team snowball rolling competition
|
||||
snowball: [109060000]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns all event types.
|
||||
"""
|
||||
def all do
|
||||
Map.keys(@event_maps)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for a given event type.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.map_ids(:coconut)
|
||||
[109080000]
|
||||
|
||||
iex> Odinsea.Game.Events.map_ids(:fitness)
|
||||
[109040000, 109040001, 109040002, 109040003, 109040004]
|
||||
"""
|
||||
def map_ids(event_type) do
|
||||
Map.get(@event_maps, event_type, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the entry map ID (first map) for an event type.
|
||||
"""
|
||||
def entry_map_id(event_type) do
|
||||
case map_ids(event_type) do
|
||||
[first | _] -> first
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an event type by string name (case-insensitive).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("coconut")
|
||||
:coconut
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("OX_QUIZ")
|
||||
:ox_quiz
|
||||
|
||||
iex> Odinsea.Game.Events.get_by_string("invalid")
|
||||
nil
|
||||
"""
|
||||
def get_by_string(str) when is_binary(str) do
|
||||
str = String.downcase(str)
|
||||
|
||||
Enum.find(all(), fn type ->
|
||||
Atom.to_string(type) == str or
|
||||
String.replace(Atom.to_string(type), "_", "") == str
|
||||
end)
|
||||
end
|
||||
|
||||
def get_by_string(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns a human-readable name for the event type.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Odinsea.Game.Events.display_name(:coconut)
|
||||
"Coconut"
|
||||
|
||||
iex> Odinsea.Game.Events.display_name(:ola_ola)
|
||||
"Ola Ola"
|
||||
"""
|
||||
def display_name(event_type) do
|
||||
event_type
|
||||
|> Atom.to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the number of stages/maps for an event.
|
||||
"""
|
||||
def stage_count(event_type) do
|
||||
length(map_ids(event_type))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a map ID belongs to any event.
|
||||
Returns the event type if found, nil otherwise.
|
||||
"""
|
||||
def event_for_map(map_id) when is_integer(map_id) do
|
||||
Enum.find(all(), fn type ->
|
||||
map_id in map_ids(type)
|
||||
end)
|
||||
end
|
||||
|
||||
def event_for_map(_), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if a map ID is the finish map (109050000).
|
||||
"""
|
||||
def finish_map?(109050000), do: true
|
||||
def finish_map?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event is a race-type event (timed).
|
||||
"""
|
||||
def race_event?(:fitness), do: true
|
||||
def race_event?(:ola_ola), do: true
|
||||
def race_event?(:survival), do: true
|
||||
def race_event?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event is team-based.
|
||||
"""
|
||||
def team_event?(:coconut), do: true
|
||||
def team_event?(:snowball), do: true
|
||||
def team_event?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the event has multiple stages.
|
||||
"""
|
||||
def multi_stage?(event_type) do
|
||||
stage_count(event_type) > 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the stage index for a map ID within an event.
|
||||
Returns 0-based index or nil if not part of event.
|
||||
"""
|
||||
def stage_index(event_type, map_id) do
|
||||
map_ids(event_type)
|
||||
|> Enum.find_index(&(&1 == map_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all event data as a map.
|
||||
"""
|
||||
def all_event_data do
|
||||
@event_maps
|
||||
end
|
||||
end
|
||||
393
lib/odinsea/game/events/coconut.ex
Normal file
393
lib/odinsea/game/events/coconut.ex
Normal file
@@ -0,0 +1,393 @@
|
||||
defmodule Odinsea.Game.Events.Coconut do
|
||||
@moduledoc """
|
||||
Coconut Event - Team-based coconut hitting competition.
|
||||
Ported from Java `server.events.MapleCoconut`.
|
||||
|
||||
## Gameplay
|
||||
- Two teams (Maple vs Story) compete to hit coconuts
|
||||
- Coconuts spawn and fall when hit
|
||||
- Team with most hits at end wins
|
||||
- 5 minute time limit with potential 1 minute bonus time
|
||||
|
||||
## Map
|
||||
- Single map: 109080000
|
||||
|
||||
## Win Condition
|
||||
- Team with higher score after 5 minutes wins
|
||||
- If tied, 1 minute bonus time is awarded
|
||||
- If still tied after bonus, no winner
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Coconut struct representing a single coconut"
|
||||
@type coconut :: %{
|
||||
id: non_neg_integer(),
|
||||
hits: non_neg_integer(),
|
||||
hittable: boolean(),
|
||||
stopped: boolean(),
|
||||
hit_time: integer() # Unix timestamp ms
|
||||
}
|
||||
|
||||
@typedoc "Coconut event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
coconuts: [coconut()],
|
||||
maple_score: non_neg_integer(), # Team 0
|
||||
story_score: non_neg_integer(), # Team 1
|
||||
count_bombing: non_neg_integer(),
|
||||
count_falling: non_neg_integer(),
|
||||
count_stopped: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109080000]
|
||||
@event_duration 300_000 # 5 minutes in ms
|
||||
@bonus_duration 60_000 # 1 minute bonus time
|
||||
@total_coconuts 506
|
||||
@warp_out_delay 10_000 # 10 seconds after game end
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Coconut event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:coconut, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
# Cancel all schedules
|
||||
Event.cancel_schedules(event.base)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Coconut event doesn't use this - winners determined by time.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Sends coconut score packet.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
# Send coconut score packet
|
||||
Logger.debug("Sending coconut score to #{character.name}: Maple #{event.maple_score}, Story #{event.story_score}")
|
||||
|
||||
# In real implementation: send packet with scores
|
||||
# Packet format: coconutScore(maple_score, story_score)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the coconut event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Coconut event on channel #{event.base.channel_id}")
|
||||
|
||||
# Set coconuts hittable
|
||||
event = set_hittable(event, true)
|
||||
|
||||
# Broadcast event start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, :hit_coconut)
|
||||
|
||||
# Start 5-minute countdown
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule end check
|
||||
schedule_ref = EventTimer.schedule(
|
||||
fn -> check_winner(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%{event | schedules: [schedule_ref]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a coconut by ID.
|
||||
Returns nil if ID is out of range.
|
||||
"""
|
||||
def get_coconut(%__MODULE__{coconuts: coconuts}, id) when id >= 0 and id < length(coconuts) do
|
||||
Enum.at(coconuts, id)
|
||||
end
|
||||
|
||||
def get_coconut(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Returns all coconuts.
|
||||
"""
|
||||
def get_all_coconuts(%__MODULE__{coconuts: coconuts}), do: coconuts
|
||||
|
||||
@doc """
|
||||
Sets whether coconuts are hittable.
|
||||
"""
|
||||
def set_hittable(%__MODULE__{coconuts: coconuts} = event, hittable) do
|
||||
updated_coconuts = Enum.map(coconuts, fn coconut ->
|
||||
%{coconut | hittable: hittable}
|
||||
end)
|
||||
|
||||
%{event | coconuts: updated_coconuts}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of available bombings.
|
||||
"""
|
||||
def get_bombings(%__MODULE__{count_bombing: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements bombing count.
|
||||
"""
|
||||
def bomb_coconut(%__MODULE__{count_bombing: count} = event) do
|
||||
%{event | count_bombing: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of falling coconuts available.
|
||||
"""
|
||||
def get_falling(%__MODULE__{count_falling: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements falling count.
|
||||
"""
|
||||
def fall_coconut(%__MODULE__{count_falling: count} = event) do
|
||||
%{event | count_falling: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of stopped coconuts.
|
||||
"""
|
||||
def get_stopped(%__MODULE__{count_stopped: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements stopped count.
|
||||
"""
|
||||
def stop_coconut(%__MODULE__{count_stopped: count} = event) do
|
||||
%{event | count_stopped: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current scores [maple, story].
|
||||
"""
|
||||
def get_coconut_score(%__MODULE__{} = event) do
|
||||
[event.maple_score, event.story_score]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets Team Maple score.
|
||||
"""
|
||||
def get_maple_score(%__MODULE__{maple_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Gets Team Story score.
|
||||
"""
|
||||
def get_story_score(%__MODULE__{story_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Maple.
|
||||
"""
|
||||
def add_maple_score(%__MODULE__{maple_score: score} = event) do
|
||||
%{event | maple_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Story.
|
||||
"""
|
||||
def add_story_score(%__MODULE__{story_score: score} = event) do
|
||||
%{event | story_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records a hit on a coconut.
|
||||
"""
|
||||
def hit_coconut(%__MODULE__{coconuts: coconuts} = event, coconut_id, team) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
updated_coconuts = List.update_at(coconuts, coconut_id, fn coconut ->
|
||||
%{coconut |
|
||||
hits: coconut.hits + 1,
|
||||
hit_time: now + 1000 # 1 second cooldown
|
||||
}
|
||||
end)
|
||||
|
||||
# Add score to appropriate team
|
||||
event = %{event | coconuts: updated_coconuts}
|
||||
|
||||
event = case team do
|
||||
0 -> add_maple_score(event)
|
||||
1 -> add_story_score(event)
|
||||
_ -> event
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp initialize_coconuts do
|
||||
Enum.map(0..(@total_coconuts - 1), fn id ->
|
||||
%{
|
||||
id: id,
|
||||
hits: 0,
|
||||
hittable: false,
|
||||
stopped: false,
|
||||
hit_time: 0
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp check_winner(%__MODULE__{} = event) do
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Tie - bonus time
|
||||
bonus_time(event)
|
||||
else
|
||||
# We have a winner
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end
|
||||
|
||||
defp bonus_time(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event tied! Starting bonus time...")
|
||||
|
||||
# Broadcast bonus time
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@bonus_duration, 1000)})
|
||||
|
||||
# Schedule final check
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Still tied - no winner
|
||||
end_game_no_winner(event)
|
||||
else
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end,
|
||||
@bonus_duration
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game(%__MODULE__{} = event, winner_team) do
|
||||
team_name = if winner_team == 0, do: "Maple", else: "Story"
|
||||
Logger.info("Coconut event ended! Team #{team_name} wins!")
|
||||
|
||||
# Broadcast winner
|
||||
Event.broadcast_to_event(event.base, {:victory, winner_team})
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, winner_team) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game_no_winner(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event ended with no winner (tie)")
|
||||
|
||||
# Broadcast no winner
|
||||
Event.broadcast_to_event(event.base, :no_winner)
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, nil) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp warp_out(%__MODULE__{} = event, winner_team) do
|
||||
# Make coconuts unhittable
|
||||
event = set_hittable(event, false)
|
||||
|
||||
# Give prizes to winners, warp everyone back
|
||||
# In real implementation:
|
||||
# - Get all characters on map
|
||||
# - For each character:
|
||||
# - If on winning team, give prize
|
||||
# - Warp back to saved location
|
||||
|
||||
Logger.info("Warping out all players from coconut event")
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
298
lib/odinsea/game/events/fitness.ex
Normal file
298
lib/odinsea/game/events/fitness.ex
Normal file
@@ -0,0 +1,298 @@
|
||||
defmodule Odinsea.Game.Events.Fitness do
|
||||
@moduledoc """
|
||||
Fitness Event - Maple Physical Fitness Test obstacle course.
|
||||
Ported from Java `server.events.MapleFitness`.
|
||||
|
||||
## Gameplay
|
||||
- 4 stage obstacle course that players must navigate
|
||||
- Time limit of 10 minutes
|
||||
- Players who reach the end within time limit get prize
|
||||
- Death during event results in elimination
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109040000 (Start - monkeys throwing bananas)
|
||||
- Stage 2: 109040001 (Stage 2 - monkeys)
|
||||
- Stage 3: 109040002 (Stage 3 - traps)
|
||||
- Stage 4: 109040003 (Stage 4 - last stage)
|
||||
- Finish: 109040004
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map within 10 minutes
|
||||
- All finishers get prize regardless of order
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Fitness event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil, # Unix timestamp ms
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 600_000, # 10 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109040000, 109040001, 109040002, 109040003, 109040004]
|
||||
@event_duration 600_000 # 10 minutes in ms
|
||||
@message_interval 60_000 # Broadcast messages every minute
|
||||
|
||||
# Message schedule based on time remaining
|
||||
@messages [
|
||||
{10_000, "You have 10 sec left. Those of you unable to beat the game, we hope you beat it next time! Great job everyone!! See you later~"},
|
||||
{110_000, "Alright, you don't have much time remaining. Please hurry up a little!"},
|
||||
{210_000, "The 4th stage is the last one for [The Maple Physical Fitness Test]. Please don't give up at the last minute and try your best. The reward is waiting for you at the very top!"},
|
||||
{310_000, "The 3rd stage offers traps where you may see them, but you won't be able to step on them. Please be careful of them as you make your way up."},
|
||||
{400_000, "For those who have heavy lags, please make sure to move slowly to avoid falling all the way down because of lags."},
|
||||
{500_000, "Please remember that if you die during the event, you'll be eliminated from the game. If you're running out of HP, either take a potion or recover HP first before moving on."},
|
||||
{600_000, "The most important thing you'll need to know to avoid the bananas thrown by the monkeys is *Timing* Timing is everything in this!"},
|
||||
{660_000, "The 2nd stage offers monkeys throwing bananas. Please make sure to avoid them by moving along at just the right timing."},
|
||||
{700_000, "Please remember that if you die during the event, you'll be eliminated from the game. You still have plenty of time left, so either take a potion or recover HP first before moving on."},
|
||||
{780_000, "Everyone that clears [The Maple Physical Fitness Test] on time will be given an item, regardless of the order of finish, so just relax, take your time, and clear the 4 stages."},
|
||||
{840_000, "There may be a heavy lag due to many users at stage 1 all at once. It won't be difficult, so please make sure not to fall down because of heavy lag."},
|
||||
{900_000, "[MapleStory Physical Fitness Test] consists of 4 stages, and if you happen to die during the game, you'll be eliminated from the game, so please be careful of that."}
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Fitness event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:fitness, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Fitness event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 20)
|
||||
Character.finish_achievement(character, 20)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending fitness clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet with time left
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the fitness event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Fitness event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
# Start message broadcasting
|
||||
msg_ref = start_message_schedule(event)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref, msg_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the time elapsed in milliseconds.
|
||||
"""
|
||||
def get_time_elapsed(%__MODULE__{time_started: nil}), do: 0
|
||||
def get_time_elapsed(%__MODULE__{time_started: started}) do
|
||||
System.system_time(:millisecond) - started
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is eliminated (died during event).
|
||||
"""
|
||||
def eliminated?(character) do
|
||||
# Check if character died while on event maps
|
||||
# This would check character state
|
||||
character.hp <= 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Eliminates a player from the event.
|
||||
"""
|
||||
def eliminate_player(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} eliminated from Fitness event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp start_message_schedule(%__MODULE__{} = event) do
|
||||
# Register recurring task for message broadcasting
|
||||
{:ok, ref} = EventTimer.register(
|
||||
fn -> check_and_broadcast_messages(event) end,
|
||||
@message_interval,
|
||||
0
|
||||
)
|
||||
|
||||
ref
|
||||
end
|
||||
|
||||
defp check_and_broadcast_messages(%__MODULE__{} = event) do
|
||||
time_left = get_time_left(event)
|
||||
|
||||
# Find messages that should be broadcast based on time left
|
||||
messages_to_send = Enum.filter(@messages, fn {threshold, _} ->
|
||||
time_left <= threshold and time_left > threshold - @message_interval
|
||||
end)
|
||||
|
||||
Enum.each(messages_to_send, fn {_, message} ->
|
||||
Event.broadcast_to_event(event.base, {:server_notice, message})
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, this would update the map portal state
|
||||
# allowing or preventing players from entering
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Fitness event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
332
lib/odinsea/game/events/ola_ola.ex
Normal file
332
lib/odinsea/game/events/ola_ola.ex
Normal file
@@ -0,0 +1,332 @@
|
||||
defmodule Odinsea.Game.Events.OlaOla do
|
||||
@moduledoc """
|
||||
Ola Ola Event - Portal guessing game (similar to Survival but with portals).
|
||||
Ported from Java `server.events.MapleOla`.
|
||||
|
||||
## Gameplay
|
||||
- 3 stages with random correct portals
|
||||
- Players must guess which portal leads forward
|
||||
- Wrong portals send players back or eliminate them
|
||||
- Fastest to finish wins
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109030001 (5 portals: ch00-ch04)
|
||||
- Stage 2: 109030002 (8 portals: ch00-ch07)
|
||||
- Stage 3: 109030003 (16 portals: ch00-ch15)
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map by choosing correct portals
|
||||
- First to finish gets best prize, all finishers get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OlaOla event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
stages: [non_neg_integer()], # Correct portal indices for each stage
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
stages: [0, 0, 0], # Will be randomized on start
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109030001, 109030002, 109030003]
|
||||
@event_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# Stage configurations
|
||||
@stage_config [
|
||||
%{map: 109030001, portals: 5, prefix: "ch"}, # Stage 1: 5 portals
|
||||
%{map: 109030002, portals: 8, prefix: "ch"}, # Stage 2: 8 portals
|
||||
%{map: 109030003, portals: 16, prefix: "ch"} # Stage 3: 16 portals
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OlaOla event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ola_ola, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
Randomizes correct portals for next game.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Randomize correct portals for each stage
|
||||
stages = [
|
||||
random_stage_portal(0), # Stage 1: 0-4
|
||||
random_stage_portal(1), # Stage 2: 0-7
|
||||
random_stage_portal(2) # Stage 3: 0-15
|
||||
]
|
||||
|
||||
# Hack check: stage 1 portal 2 is inaccessible
|
||||
stages = if Enum.at(stages, 0) == 2 do
|
||||
List.replace_at(stages, 0, 3)
|
||||
else
|
||||
stages
|
||||
end
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: stages,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Ola Ola event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 21)
|
||||
Character.finish_achievement(character, 21)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Ola Ola clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Ola Ola event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Ola Ola event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character chose the correct portal for their current stage.
|
||||
|
||||
## Parameters
|
||||
- event: The OlaOla event state
|
||||
- portal_name: The portal name (e.g., "ch00", "ch05")
|
||||
- map_id: Current map ID
|
||||
|
||||
## Returns
|
||||
- true if correct portal
|
||||
- false if wrong portal
|
||||
"""
|
||||
def correct_portal?(%__MODULE__{stages: stages}, portal_name, map_id) do
|
||||
# Get stage index from map ID
|
||||
stage_index = get_stage_index(map_id)
|
||||
|
||||
if stage_index == nil do
|
||||
false
|
||||
else
|
||||
# Get correct portal for this stage
|
||||
correct = Enum.at(stages, stage_index)
|
||||
|
||||
# Format correct portal name
|
||||
correct_name = format_portal_name(correct)
|
||||
|
||||
portal_name == correct_name
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the correct portal name for a stage.
|
||||
"""
|
||||
def get_correct_portal(%__MODULE__{stages: stages}, stage_index) when stage_index in 0..2 do
|
||||
correct = Enum.at(stages, stage_index)
|
||||
format_portal_name(correct)
|
||||
end
|
||||
|
||||
def get_correct_portal(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current stage (0-2) for a map ID.
|
||||
"""
|
||||
def get_stage_index(map_id) do
|
||||
Enum.find_index(@map_ids, &(&1 == map_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the stage configuration.
|
||||
"""
|
||||
def stage_config, do: @stage_config
|
||||
|
||||
@doc """
|
||||
Handles a player attempting to use a portal.
|
||||
Returns {:ok, destination_map} for correct portal, :error for wrong portal.
|
||||
"""
|
||||
def attempt_portal(%__MODULE__{} = event, portal_name, current_map_id) do
|
||||
if correct_portal?(event, portal_name, current_map_id) do
|
||||
# Correct portal - advance to next stage
|
||||
stage = get_stage_index(current_map_id)
|
||||
|
||||
if stage < 2 do
|
||||
next_map = Enum.at(@map_ids, stage + 1)
|
||||
{:ok, next_map}
|
||||
else
|
||||
# Finished all stages
|
||||
{:finished, 109050000} # Finish map
|
||||
end
|
||||
else
|
||||
# Wrong portal - fail
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp random_stage_portal(stage_index) do
|
||||
portal_count = Enum.at(@stage_config, stage_index).portals
|
||||
:rand.uniform(portal_count) - 1 # 0-based
|
||||
end
|
||||
|
||||
defp format_portal_name(portal_num) do
|
||||
# Format as ch00, ch01, etc.
|
||||
if portal_num < 10 do
|
||||
"ch0#{portal_num}"
|
||||
else
|
||||
"ch#{portal_num}"
|
||||
end
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Ola Ola event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation, get all players on event maps and warp them
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
@@ -0,0 +1,349 @@
|
||||
defmodule Odinsea.Game.Events.OxQuiz do
|
||||
@moduledoc """
|
||||
OX Quiz Event - True/False quiz game with position-based answers.
|
||||
Ported from Java `server.events.MapleOxQuiz`.
|
||||
|
||||
## Gameplay
|
||||
- 10 questions are asked
|
||||
- Players stand on O (true/right side) or X (false/left side) side
|
||||
- Wrong answer = eliminated (HP set to 0)
|
||||
- Correct answer = gain EXP
|
||||
- Last players standing win
|
||||
|
||||
## Maps
|
||||
- Single map: 109020001
|
||||
- X side: x < -234, y > -26
|
||||
- O side: x > -234, y > -26
|
||||
|
||||
## Win Condition
|
||||
- Answer correctly to survive all 10 questions
|
||||
- Remaining players at end get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Events.OxQuizQuestions
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
times_asked: non_neg_integer(),
|
||||
current_question: OxQuizQuestions.question() | nil,
|
||||
finished: boolean(),
|
||||
question_delay: non_neg_integer(), # ms before showing question
|
||||
answer_delay: non_neg_integer(), # ms before revealing answer
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109020001]
|
||||
@max_questions 10
|
||||
|
||||
# Position boundaries for O (true) vs X (false)
|
||||
@o_side_bounds %{x_min: -234, x_max: 9999, y_min: -26, y_max: 9999}
|
||||
@x_side_bounds %{x_min: -9999, x_max: -234, y_min: -26, y_max: 9999}
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OX Quiz event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ox_quiz, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes.
|
||||
OX Quiz doesn't use this - winners determined by survival.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Unmutes player (allows chat during quiz).
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = _event, character) do
|
||||
# Unmute player (allow chat)
|
||||
# In real implementation: Character.set_temp_mute(character, false)
|
||||
Logger.debug("Player #{character.name} loaded OX Quiz map, unmuting")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz event gameplay.
|
||||
Begins asking questions.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting OX Quiz event on channel #{event.base.channel_id}")
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Start asking questions
|
||||
send_question(%{event | finished: false})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the next question to all players.
|
||||
"""
|
||||
def send_question(%__MODULE__{finished: true} = event), do: event
|
||||
|
||||
def send_question(%__MODULE__{} = event) do
|
||||
# Grab random question
|
||||
question = OxQuizQuestions.get_random_question()
|
||||
|
||||
# Schedule question display
|
||||
question_ref = EventTimer.schedule(
|
||||
fn -> display_question(event, question) end,
|
||||
event.question_delay
|
||||
)
|
||||
|
||||
# Schedule answer reveal
|
||||
answer_ref = EventTimer.schedule(
|
||||
fn -> reveal_answer(event, question) end,
|
||||
event.question_delay + event.answer_delay
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
current_question: question,
|
||||
schedules: [question_ref, answer_ref | event.schedules]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Displays the question to all players.
|
||||
"""
|
||||
def display_question(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def display_question(%__MODULE__{} = event, question) do
|
||||
# Check end conditions
|
||||
if should_end_event?(event) do
|
||||
end_event(event)
|
||||
else
|
||||
# Broadcast question
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_show, question_set, question_id, true})
|
||||
Event.broadcast_to_event(event.base, {:clock, 10}) # 10 seconds to answer
|
||||
|
||||
Logger.debug("OX Quiz: Displaying question #{question_id} from set #{question_set}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reveals the answer and processes results.
|
||||
"""
|
||||
def reveal_answer(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def reveal_answer(%__MODULE__{} = event, question) do
|
||||
if event.finished do
|
||||
:ok
|
||||
else
|
||||
# Broadcast answer reveal
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_hide, question_set, question_id})
|
||||
|
||||
# Process each player
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Check their position vs answer
|
||||
# - Wrong position: set HP to 0
|
||||
# - Correct position: give EXP
|
||||
|
||||
Logger.debug("OX Quiz: Revealing answer for question #{question_id}: #{question.answer}")
|
||||
|
||||
# Increment question count
|
||||
event = %{event | times_asked: event.times_asked + 1}
|
||||
|
||||
# Continue to next question
|
||||
send_question(event)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player's position corresponds to the correct answer.
|
||||
|
||||
## Parameters
|
||||
- answer: :o (true) or :x (false)
|
||||
- x: Player X position
|
||||
- y: Player Y position
|
||||
|
||||
## Returns
|
||||
- true if position matches answer
|
||||
- false if wrong position
|
||||
"""
|
||||
def correct_position?(:o, x, y) do
|
||||
x > -234 and y > -26
|
||||
end
|
||||
|
||||
def correct_position?(:x, x, y) do
|
||||
x < -234 and y > -26
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a player's answer based on their position.
|
||||
Returns {:correct, exp} or {:wrong, 0}
|
||||
"""
|
||||
def check_player_answer(question_answer, player_x, player_y) do
|
||||
player_answer = if player_x > -234, do: :o, else: :x
|
||||
|
||||
if player_answer == question_answer do
|
||||
{:correct, 3000} # 3000 EXP for correct answer
|
||||
else
|
||||
{:wrong, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current question number.
|
||||
"""
|
||||
def current_question_number(%__MODULE__{times_asked: asked}), do: asked + 1
|
||||
|
||||
@doc """
|
||||
Gets the maximum number of questions.
|
||||
"""
|
||||
def max_questions, do: @max_questions
|
||||
|
||||
@doc """
|
||||
Mutes a player (after event ends).
|
||||
"""
|
||||
def mute_player(character) do
|
||||
# In real implementation: Character.set_temp_mute(character, true)
|
||||
Logger.debug("Muting player #{character.name}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp should_end_event?(%__MODULE__{} = event) do
|
||||
# End if 10 questions asked or only 1 player left
|
||||
event.times_asked >= @max_questions or count_alive_players(event) <= 1
|
||||
end
|
||||
|
||||
defp count_alive_players(%__MODULE__{} = _event) do
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Count non-GM, alive players
|
||||
# For now, return placeholder
|
||||
10
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("OX Quiz event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Mark as finished
|
||||
event = %{event | finished: true}
|
||||
|
||||
# Broadcast end
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The event has ended"})
|
||||
|
||||
# Process winners
|
||||
# In real implementation:
|
||||
# - Get all alive, non-GM players
|
||||
# - Give prize to each
|
||||
# - Give achievement (ID 19)
|
||||
# - Mute players
|
||||
# - Warp back
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule Odinsea.Game.Events.OxQuizQuestions do
|
||||
@moduledoc """
|
||||
OX Quiz Question Database.
|
||||
Ported from Java `server.events.MapleOxQuizFactory`.
|
||||
|
||||
Stores true/false questions loaded from database or fallback data.
|
||||
Questions are organized into sets and IDs for efficient lookup.
|
||||
|
||||
## Question Format
|
||||
- question: The question text
|
||||
- display: How to display the answer (O/X)
|
||||
- answer: :o for true, :x for false
|
||||
- question_set: Category/set number
|
||||
- question_id: ID within the set
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz question struct"
|
||||
@type question :: %{
|
||||
question: String.t(),
|
||||
display: String.t(),
|
||||
answer: :o | :x,
|
||||
ids: {non_neg_integer(), non_neg_integer()} # {question_set, question_id}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# GenServer State
|
||||
# ============================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
defstruct [
|
||||
:questions, # Map of {{set, id} => question}
|
||||
:ets_table
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz question cache.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random question from the database.
|
||||
"""
|
||||
def get_random_question do
|
||||
GenServer.call(__MODULE__, :get_random_question)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a specific question by set and ID.
|
||||
"""
|
||||
def get_question(question_set, question_id) do
|
||||
GenServer.call(__MODULE__, {:get_question, question_set, question_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all questions.
|
||||
"""
|
||||
def get_all_questions do
|
||||
GenServer.call(__MODULE__, :get_all_questions)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total number of questions.
|
||||
"""
|
||||
def question_count do
|
||||
GenServer.call(__MODULE__, :question_count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads questions from database.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.cast(__MODULE__, :reload)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
# Create ETS table for fast lookups
|
||||
ets = :ets.new(:ox_quiz_questions, [:set, :protected, :named_table])
|
||||
|
||||
# Load initial questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(ets, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions loaded: #{map_size(questions)} questions")
|
||||
|
||||
{:ok, %__MODULE__{questions: questions, ets_table: ets}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_random_question, _from, state) do
|
||||
question = get_random_question_impl(state)
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_question, set, id}, _from, state) do
|
||||
question = Map.get(state.questions, {set, id})
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_all_questions, _from, state) do
|
||||
{:reply, Map.values(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:question_count, _from, state) do
|
||||
{:reply, map_size(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:reload, state) do
|
||||
# Clear ETS
|
||||
:ets.delete_all_objects(state.ets_table)
|
||||
|
||||
# Reload questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(state.ets_table, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions reloaded: #{map_size(questions)} questions")
|
||||
|
||||
{:noreply, %{state | questions: questions}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_random_question_impl(state) do
|
||||
questions = Map.values(state.questions)
|
||||
|
||||
if length(questions) > 0 do
|
||||
Enum.random(questions)
|
||||
else
|
||||
# Return fallback question if none loaded
|
||||
fallback_question()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_questions do
|
||||
# Try to load from database
|
||||
# In real implementation:
|
||||
# - Query wz_oxdata table
|
||||
# - Parse each row into question struct
|
||||
|
||||
# For now, use fallback questions
|
||||
fallback_questions()
|
||||
|> Enum.map(fn q -> {{elem(q.ids, 0), elem(q.ids, 1)}, q} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp parse_answer("o"), do: :o
|
||||
defp parse_answer("O"), do: :o
|
||||
defp parse_answer("x"), do: :x
|
||||
defp parse_answer("X"), do: :x
|
||||
defp parse_answer(_), do: :o # Default to true
|
||||
|
||||
# ============================================================================
|
||||
# Fallback Questions
|
||||
# ============================================================================
|
||||
|
||||
defp fallback_question do
|
||||
%{
|
||||
question: "MapleStory was first released in 2003?",
|
||||
display: "O",
|
||||
answer: :o,
|
||||
ids: {0, 0}
|
||||
}
|
||||
end
|
||||
|
||||
defp fallback_questions do
|
||||
[
|
||||
# Set 1: General MapleStory Knowledge
|
||||
%{question: "MapleStory was first released in 2003?", display: "O", answer: :o, ids: {1, 1}},
|
||||
%{question: "The maximum level in MapleStory is 200?", display: "O", answer: :o, ids: {1, 2}},
|
||||
%{question: "Henesys is the starting town for all beginners?", display: "X", answer: :x, ids: {1, 3}},
|
||||
%{question: "The Pink Bean is a boss monster?", display: "O", answer: :o, ids: {1, 4}},
|
||||
%{question: "Magicians use swords as their primary weapon?", display: "X", answer: :x, ids: {1, 5}},
|
||||
%{question: "The EXP curve gets steeper at higher levels?", display: "O", answer: :o, ids: {1, 6}},
|
||||
%{question: "Gachapon gives random items for NX?", display: "O", answer: :o, ids: {1, 7}},
|
||||
%{question: "Warriors have the highest INT growth?", display: "X", answer: :x, ids: {1, 8}},
|
||||
%{question: "The Cash Shop sells permanent pets?", display: "O", answer: :o, ids: {1, 9}},
|
||||
%{question: "All monsters in Maple Island are passive?", display: "O", answer: :o, ids: {1, 10}},
|
||||
|
||||
# Set 2: Classes and Jobs
|
||||
%{question: "Beginners can use the Three Snails skill?", display: "O", answer: :o, ids: {2, 1}},
|
||||
%{question: "Magicians require the most DEX to advance?", display: "X", answer: :x, ids: {2, 2}},
|
||||
%{question: "Thieves can use claws and daggers?", display: "O", answer: :o, ids: {2, 3}},
|
||||
%{question: "Pirates are the only class that can use guns?", display: "O", answer: :o, ids: {2, 4}},
|
||||
%{question: "Archers specialize in close-range combat?", display: "X", answer: :x, ids: {2, 5}},
|
||||
%{question: "First job advancement happens at level 10?", display: "O", answer: :o, ids: {2, 6}},
|
||||
%{question: "All classes can use magic attacks?", display: "X", answer: :x, ids: {2, 7}},
|
||||
%{question: "Bowmen require arrows to attack?", display: "O", answer: :o, ids: {2, 8}},
|
||||
%{question: "Warriors have the highest HP pool?", display: "O", answer: :o, ids: {2, 9}},
|
||||
%{question: "Cygnus Knights are available at level 1?", display: "X", answer: :x, ids: {2, 10}},
|
||||
|
||||
# Set 3: Monsters and Maps
|
||||
%{question: "Blue Snails are found on Maple Island?", display: "O", answer: :o, ids: {3, 1}},
|
||||
%{question: "Zakum is located in the Dead Mine?", display: "O", answer: :o, ids: {3, 2}},
|
||||
%{question: "Pigs drop pork items?", display: "O", answer: :o, ids: {3, 3}},
|
||||
%{question: "The highest level map is Victoria Island?", display: "X", answer: :x, ids: {3, 4}},
|
||||
%{question: "Balrog is a level 100 boss?", display: "O", answer: :o, ids: {3, 5}},
|
||||
%{question: "Mushmom is a giant mushroom monster?", display: "O", answer: :o, ids: {3, 6}},
|
||||
%{question: "All monsters respawn immediately after death?", display: "X", answer: :x, ids: {3, 7}},
|
||||
%{question: "Jr. Balrog spawns in Sleepywood Dungeon?", display: "O", answer: :o, ids: {3, 8}},
|
||||
%{question: "Orbis Tower connects Orbis to El Nath?", display: "O", answer: :o, ids: {3, 9}},
|
||||
%{question: "Ludibrium is a town made of toys?", display: "O", answer: :o, ids: {3, 10}},
|
||||
|
||||
# Set 4: Items and Equipment
|
||||
%{question: "Equipment can have potential stats?", display: "O", answer: :o, ids: {4, 1}},
|
||||
%{question: "Mesos are the currency of MapleStory?", display: "O", answer: :o, ids: {4, 2}},
|
||||
%{question: "Scrolls always succeed?", display: "X", answer: :x, ids: {4, 3}},
|
||||
%{question: "Potions restore HP and MP?", display: "O", answer: :o, ids: {4, 4}},
|
||||
%{question: " NX Cash is required to buy Cash Shop items?", display: "O", answer: :o, ids: {4, 5}},
|
||||
%{question: "All equipment can be traded?", display: "X", answer: :x, ids: {4, 6}},
|
||||
%{question: "Stars are thrown by Night Lords?", display: "O", answer: :o, ids: {4, 7}},
|
||||
%{question: "Beginners can equip level 100 items?", display: "X", answer: :x, ids: {4, 8}},
|
||||
%{question: "Clean Slate Scrolls remove failed slots?", display: "O", answer: :o, ids: {4, 9}},
|
||||
%{question: "Chaos Scrolls randomize item stats?", display: "O", answer: :o, ids: {4, 10}},
|
||||
|
||||
# Set 5: Quests and NPCs
|
||||
%{question: "Mai is the first quest NPC beginners meet?", display: "O", answer: :o, ids: {5, 1}},
|
||||
%{question: "All quests can be repeated?", display: "X", answer: :x, ids: {5, 2}},
|
||||
%{question: "NPCs with \"!\" above them give quests?", display: "O", answer: :o, ids: {5, 3}},
|
||||
%{question: "Party quests require exactly 6 players?", display: "X", answer: :x, ids: {5, 4}},
|
||||
%{question: "Roger sells potions in Henesys?", display: "X", answer: :x, ids: {5, 5}},
|
||||
%{question: "The Lost City is another name for Kerning City?", display: "X", answer: :x, ids: {5, 6}},
|
||||
%{question: "Guilds can have up to 200 members?", display: "O", answer: :o, ids: {5, 7}},
|
||||
%{question: "All NPCs can be attacked?", display: "X", answer: :x, ids: {5, 8}},
|
||||
%{question: "Big Headward sells hairstyles?", display: "O", answer: :o, ids: {5, 9}},
|
||||
%{question: "The Storage Keeper stores items for free?", display: "X", answer: :x, ids: {5, 10}},
|
||||
|
||||
# Set 6: Game Mechanics
|
||||
%{question: "Fame can be given or taken once per day?", display: "O", answer: :o, ids: {6, 1}},
|
||||
%{question: "Party play gives bonus EXP?", display: "O", answer: :o, ids: {6, 2}},
|
||||
%{question: "Dying causes EXP loss?", display: "O", answer: :o, ids: {6, 3}},
|
||||
%{question: "All skills have no cooldown?", display: "X", answer: :x, ids: {6, 4}},
|
||||
%{question: "Trade window allows up to 9 items?", display: "O", answer: :o, ids: {6, 5}},
|
||||
%{question: "Mounting a pet requires level 70?", display: "X", answer: :x, ids: {6, 6}},
|
||||
%{question: "Monster Book tracks monster information?", display: "O", answer: :o, ids: {6, 7}},
|
||||
%{question: "Bosses have purple health bars?", display: "O", answer: :o, ids: {6, 8}},
|
||||
%{question: "Channel changing is instant?", display: "X", answer: :x, ids: {6, 9}},
|
||||
%{question: "Expedition mode is for large boss fights?", display: "O", answer: :o, ids: {6, 10}},
|
||||
|
||||
# Set 7: Trivia
|
||||
%{question: "MapleStory is developed by Nexon?", display: "O", answer: :o, ids: {7, 1}},
|
||||
%{question: "The Black Mage is the main antagonist?", display: "O", answer: :o, ids: {7, 2}},
|
||||
%{question: "Elvis is a monster in MapleStory?", display: "X", answer: :x, ids: {7, 3}},
|
||||
%{question: "Golems are made of rock?", display: "O", answer: :o, ids: {7, 4}},
|
||||
%{question: "Maple Island is shaped like a maple leaf?", display: "O", answer: :o, ids: {7, 5}},
|
||||
%{question: "All classes can fly?", display: "X", answer: :x, ids: {7, 6}},
|
||||
%{question: "The Moon Bunny is a boss?", display: "X", answer: :x, ids: {7, 7}},
|
||||
%{question: "Scissors of Karma make items tradable?", display: "O", answer: :o, ids: {7, 8}},
|
||||
%{question: "Monster Life is a farming minigame?", display: "O", answer: :o, ids: {7, 9}},
|
||||
%{question: "FM stands for Free Market?", display: "O", answer: :o, ids: {7, 10}}
|
||||
]
|
||||
end
|
||||
end
|
||||
437
lib/odinsea/game/events/snowball.ex
Normal file
437
lib/odinsea/game/events/snowball.ex
Normal file
@@ -0,0 +1,437 @@
|
||||
defmodule Odinsea.Game.Events.Snowball do
|
||||
@moduledoc """
|
||||
Snowball Event - Team-based snowball rolling competition.
|
||||
Ported from Java `server.events.MapleSnowball`.
|
||||
|
||||
## Gameplay
|
||||
- Two teams (Story and Maple) compete to roll snowballs
|
||||
- Players hit snowballs to move them forward
|
||||
- Snowmen block the opposing team's snowball
|
||||
- First team to reach position 899 wins
|
||||
|
||||
## Maps
|
||||
- Single map: 109060000
|
||||
|
||||
## Teams
|
||||
- Team 0 (Story): Bottom snowball, y > -80
|
||||
- Team 1 (Maple): Top snowball, y <= -80
|
||||
|
||||
## Win Condition
|
||||
- First team to push snowball past position 899 wins
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Snowball state struct"
|
||||
@type snowball :: %{
|
||||
team: 0 | 1,
|
||||
position: non_neg_integer(), # 0-899
|
||||
start_point: non_neg_integer(), # Stage progress
|
||||
invis: boolean(),
|
||||
hittable: boolean(),
|
||||
snowman_hp: non_neg_integer(),
|
||||
schedule: reference() | nil
|
||||
}
|
||||
|
||||
@typedoc "Snowball event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
snowballs: %{0 => snowball(), 1 => snowball()},
|
||||
game_active: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109060000]
|
||||
@finish_position 899
|
||||
@snowman_max_hp 7500
|
||||
@snowman_invincible_time 10_000 # 10 seconds
|
||||
|
||||
# Stage positions
|
||||
@stage_positions [255, 511, 767]
|
||||
|
||||
# Damage values
|
||||
@damage_normal 10
|
||||
@damage_snowman 15
|
||||
@damage_snowman_crit 45
|
||||
@damage_snowman_miss 0
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Snowball event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:snowball, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
# Initialize snowballs for both teams
|
||||
snowballs = %{
|
||||
0 => create_snowball(0),
|
||||
1 => create_snowball(1)
|
||||
}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
snowballs: snowballs,
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
# Cancel all snowball schedules
|
||||
Enum.each(event.snowballs, fn {_, ball} ->
|
||||
if ball.schedule do
|
||||
EventTimer.cancel(ball.schedule)
|
||||
end
|
||||
end)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
snowballs: %{},
|
||||
game_active: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes.
|
||||
Snowball doesn't use this - winner determined by position.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the snowball event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Snowball event on channel #{event.base.channel_id}")
|
||||
|
||||
# Initialize snowballs
|
||||
snowballs = %{
|
||||
0 => %{create_snowball(0) | invis: false, hittable: true},
|
||||
1 => %{create_snowball(1) | invis: false, hittable: true}
|
||||
}
|
||||
|
||||
event = %{event | snowballs: snowballs, game_active: true}
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, :enter_snowball)
|
||||
|
||||
# Broadcast initial snowball state
|
||||
broadcast_snowball_update(event, 0)
|
||||
broadcast_snowball_update(event, 1)
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a snowball by team.
|
||||
"""
|
||||
def get_snowball(%__MODULE__{snowballs: balls}, team) when team in [0, 1] do
|
||||
Map.get(balls, team)
|
||||
end
|
||||
|
||||
def get_snowball(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Gets both snowballs.
|
||||
"""
|
||||
def get_all_snowballs(%__MODULE__{snowballs: balls}), do: balls
|
||||
|
||||
@doc """
|
||||
Handles a player hitting a snowball.
|
||||
|
||||
## Parameters
|
||||
- event: Snowball event state
|
||||
- character: The character hitting
|
||||
- position: Character position %{x, y}
|
||||
"""
|
||||
def hit_snowball(%__MODULE__{game_active: false}, _, _) do
|
||||
# Game not active
|
||||
:ok
|
||||
end
|
||||
|
||||
def hit_snowball(%__MODULE__{} = event, character, %{x: x, y: y}) do
|
||||
# Determine team based on Y position
|
||||
team = if y > -80, do: 0, else: 1
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball == nil or ball.invis do
|
||||
:ok
|
||||
else
|
||||
# Check if hitting snowman or snowball
|
||||
snowman = x < -360 and x > -560
|
||||
|
||||
if not snowman do
|
||||
# Hitting the snowball
|
||||
handle_snowball_hit(event, ball, character, x)
|
||||
else
|
||||
# Hitting the snowman
|
||||
handle_snowman_hit(event, ball, team)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a snowball's position.
|
||||
"""
|
||||
def update_position(%__MODULE__{} = event, team, new_position) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | position: new_position}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
|
||||
# Check for stage transitions
|
||||
if new_position in @stage_positions do
|
||||
updated_ball = %{updated_ball | start_point: updated_ball.start_point + 1}
|
||||
broadcast_message(event, team, updated_ball.start_point)
|
||||
end
|
||||
|
||||
# Check for finish
|
||||
if new_position >= @finish_position do
|
||||
end_game(event, team)
|
||||
else
|
||||
broadcast_roll(event)
|
||||
%{event | snowballs: snowballs}
|
||||
end
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a snowball's hittable state.
|
||||
"""
|
||||
def set_hittable(%__MODULE__{} = event, team, hittable) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | hittable: hittable}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a snowball's visibility.
|
||||
"""
|
||||
def set_invis(%__MODULE__{} = event, team, invis) when team in [0, 1] do
|
||||
ball = get_snowball(event, team)
|
||||
|
||||
if ball do
|
||||
updated_ball = %{ball | invis: invis}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
else
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp create_snowball(team) do
|
||||
%{
|
||||
team: team,
|
||||
position: 0,
|
||||
start_point: 0,
|
||||
invis: true,
|
||||
hittable: true,
|
||||
snowman_hp: @snowman_max_hp,
|
||||
schedule: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp handle_snowball_hit(event, ball, character, char_x) do
|
||||
# Calculate damage
|
||||
damage = calculate_snowball_damage(ball, char_x)
|
||||
|
||||
if damage > 0 and ball.hittable do
|
||||
# Move snowball
|
||||
new_position = ball.position + 1
|
||||
update_position(event, ball.team, new_position)
|
||||
else
|
||||
# Knockback chance (20%)
|
||||
if :rand.uniform() < 0.2 do
|
||||
# Send knockback packet
|
||||
send_knockback(character)
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_snowman_hit(event, ball, team) do
|
||||
# Calculate damage
|
||||
roll = :rand.uniform()
|
||||
|
||||
damage = cond do
|
||||
roll < 0.05 -> @damage_snowman_crit # 5% crit
|
||||
roll < 0.35 -> @damage_snowman_miss # 30% miss
|
||||
true -> @damage_snowman # 65% normal
|
||||
end
|
||||
|
||||
if damage > 0 do
|
||||
new_hp = ball.snowman_hp - damage
|
||||
|
||||
if new_hp <= 0 do
|
||||
# Snowman destroyed - make enemy ball unhittable
|
||||
new_hp = @snowman_max_hp
|
||||
|
||||
enemy_team = if team == 0, do: 1, else: 0
|
||||
event = set_hittable(event, enemy_team, false)
|
||||
|
||||
# Broadcast message
|
||||
broadcast_message(event, enemy_team, 4)
|
||||
|
||||
# Schedule re-hittable
|
||||
schedule_ref = EventTimer.schedule(
|
||||
fn ->
|
||||
set_hittable(event, enemy_team, true)
|
||||
broadcast_message(event, enemy_team, 5)
|
||||
end,
|
||||
@snowman_invincible_time
|
||||
)
|
||||
|
||||
# Update ball with schedule
|
||||
enemy_ball = get_snowball(event, enemy_team)
|
||||
if enemy_ball do
|
||||
updated_enemy = %{enemy_ball | schedule: schedule_ref}
|
||||
snowballs = Map.put(event.snowballs, enemy_team, updated_enemy)
|
||||
event = %{event | snowballs: snowballs}
|
||||
end
|
||||
|
||||
# Apply seduce debuff to enemy team
|
||||
apply_seduce(event, enemy_team)
|
||||
end
|
||||
|
||||
# Update snowman HP
|
||||
updated_ball = %{ball | snowman_hp: new_hp}
|
||||
snowballs = Map.put(event.snowballs, team, updated_ball)
|
||||
%{event | snowballs: snowballs}
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp calculate_snowball_damage(ball, char_x) do
|
||||
left_x = get_left_x(ball)
|
||||
right_x = get_right_x(ball)
|
||||
|
||||
# 1% chance for damage, or if in hit zone
|
||||
if :rand.uniform() < 0.01 or (char_x > left_x and char_x < right_x) do
|
||||
@damage_normal
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp get_left_x(%{position: pos}) do
|
||||
pos * 3 + 175
|
||||
end
|
||||
|
||||
defp get_right_x(ball) do
|
||||
get_left_x(ball) + 275
|
||||
end
|
||||
|
||||
defp broadcast_snowball_update(%__MODULE__{} = event, team) do
|
||||
ball = get_snowball(event, team)
|
||||
if ball do
|
||||
# Broadcast snowball state
|
||||
Event.broadcast_to_event(event.base, {:snowball_message, team, ball.start_point})
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_message(%__MODULE__{} = event, team, message) do
|
||||
Event.broadcast_to_event(event.base, {:snowball_message, team, message})
|
||||
end
|
||||
|
||||
defp broadcast_roll(%__MODULE__{} = event) do
|
||||
ball0 = get_snowball(event, 0)
|
||||
ball1 = get_snowball(event, 1)
|
||||
|
||||
Event.broadcast_to_event(event.base, {:roll_snowball, ball0, ball1})
|
||||
end
|
||||
|
||||
defp send_knockback(_character) do
|
||||
# Send knockback packet to character
|
||||
:ok
|
||||
end
|
||||
|
||||
defp apply_seduce(_event, _team) do
|
||||
# Apply seduce debuff to enemy team
|
||||
# This would use MobSkillFactory to apply debuff
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_game(%__MODULE__{} = event, winner_team) do
|
||||
team_name = if winner_team == 0, do: "Story", else: "Maple"
|
||||
Logger.info("Snowball event ended! Team #{team_name} wins!")
|
||||
|
||||
# Make both snowballs invisible
|
||||
event = set_invis(event, 0, true)
|
||||
event = set_invis(event, 1, true)
|
||||
|
||||
# Broadcast winner
|
||||
Event.broadcast_to_event(
|
||||
event.base,
|
||||
{:server_notice, "Congratulations! Team #{team_name} has won the Snowball Event!"}
|
||||
)
|
||||
|
||||
# Give prizes to winners
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Winners (based on Y position) get prize
|
||||
# - Everyone gets warped back
|
||||
|
||||
# Unreset event
|
||||
unreset(%{event | game_active: false})
|
||||
end
|
||||
end
|
||||
247
lib/odinsea/game/events/survival.ex
Normal file
247
lib/odinsea/game/events/survival.ex
Normal file
@@ -0,0 +1,247 @@
|
||||
defmodule Odinsea.Game.Events.Survival do
|
||||
@moduledoc """
|
||||
Survival Event - Last-man-standing platform challenge.
|
||||
Ported from Java `server.events.MapleSurvival`.
|
||||
|
||||
## Gameplay
|
||||
- Players must navigate platforms without falling
|
||||
- Fall once = elimination
|
||||
- Last players to survive win
|
||||
|
||||
## Maps
|
||||
- Stage 1: 809040000
|
||||
- Stage 2: 809040100
|
||||
|
||||
## Win Condition
|
||||
- Survive until time runs out
|
||||
- Last players standing win
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Survival event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes default
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [809040000, 809040100]
|
||||
@default_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Survival event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:survival, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @default_duration,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the map IDs for this event type.
|
||||
"""
|
||||
def map_ids, do: @map_ids
|
||||
|
||||
@doc """
|
||||
Resets the event state for a new game.
|
||||
"""
|
||||
def reset(%__MODULE__{} = event) do
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Survival event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 25)
|
||||
Character.finish_achievement(character, 25)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Survival clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Survival event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Survival event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(event.event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
event.event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil, event_duration: duration}), do: duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player falling (elimination).
|
||||
"""
|
||||
def player_fell(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} fell and was eliminated from Survival event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player position is valid (on platform).
|
||||
Falling below a certain Y coordinate = elimination.
|
||||
"""
|
||||
def valid_position?(%__MODULE__{}, %{y: y}) do
|
||||
# Y threshold for falling (map-specific)
|
||||
y > -500 # Example threshold
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Survival event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Give prizes to survivors
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
599
lib/odinsea/game/hired_merchant.ex
Normal file
599
lib/odinsea/game/hired_merchant.ex
Normal file
@@ -0,0 +1,599 @@
|
||||
defmodule Odinsea.Game.HiredMerchant do
|
||||
@moduledoc """
|
||||
Hired Merchant (permanent NPC shop) system.
|
||||
Ported from src/server/shops/HiredMerchant.java
|
||||
|
||||
Hired Merchants are permanent shops that:
|
||||
- Stay open even when the owner is offline
|
||||
- Can be placed in the Free Market
|
||||
- Support visitor browsing and buying
|
||||
- Have a blacklist system
|
||||
- Can save items to Fredrick when closed
|
||||
|
||||
Shop lifecycle:
|
||||
1. Owner uses hired merchant item
|
||||
2. Shop is created and items are added
|
||||
3. Shop stays open for extended period (or until owner closes it)
|
||||
4. When closed, unsold items and mesos can be retrieved from Fredrick
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{ShopItem, Item, Equip}
|
||||
|
||||
# Shop type constant
|
||||
@shop_type 1
|
||||
|
||||
# Maximum visitors
|
||||
@max_visitors 3
|
||||
|
||||
# Hired merchant duration (24 hours in milliseconds)
|
||||
@merchant_duration 24 * 60 * 60 * 1000
|
||||
|
||||
# Struct for the merchant state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_account_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:map_id,
|
||||
:channel,
|
||||
:position,
|
||||
:store_id,
|
||||
:meso,
|
||||
:items,
|
||||
:visitors,
|
||||
:visitor_names,
|
||||
:blacklist,
|
||||
:open,
|
||||
:available,
|
||||
:bought_items,
|
||||
:start_time
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new hired merchant GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
merchant_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(merchant_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new hired merchant.
|
||||
"""
|
||||
def create(opts) do
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_account_id: opts[:owner_account_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
position: opts[:position],
|
||||
store_id: 0,
|
||||
meso: 0,
|
||||
items: [],
|
||||
visitors: %{},
|
||||
visitor_names: [],
|
||||
blacklist: [],
|
||||
open: false,
|
||||
available: false,
|
||||
bought_items: [],
|
||||
start_time: System.system_time(:millisecond)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type (1 = hired merchant).
|
||||
"""
|
||||
def shop_type, do: @shop_type
|
||||
|
||||
@doc """
|
||||
Gets the current merchant state.
|
||||
"""
|
||||
def get_state(merchant_pid) when is_pid(merchant_pid) do
|
||||
GenServer.call(merchant_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(merchant_id) do
|
||||
case lookup(merchant_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a merchant by ID.
|
||||
"""
|
||||
def lookup(merchant_id) do
|
||||
case Registry.lookup(Odinsea.MerchantRegistry, merchant_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an item to the merchant.
|
||||
"""
|
||||
def add_item(merchant_id, %ShopItem{} = item) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_item, item})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys an item from the merchant.
|
||||
Returns {:ok, item, price} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def buy_item(merchant_id, slot, quantity, buyer_id, buyer_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Searches for items by item ID in the merchant.
|
||||
"""
|
||||
def search_item(merchant_id, item_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:search_item, item_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the merchant.
|
||||
Returns the visitor slot (1-3) or {:error, :full/:blacklisted}.
|
||||
"""
|
||||
def add_visitor(merchant_id, character_id, character_name, character_pid) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_name, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the merchant.
|
||||
"""
|
||||
def remove_visitor(merchant_id, character_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the merchant open status.
|
||||
"""
|
||||
def set_open(merchant_id, open) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_open, open})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the merchant available status (visible on map).
|
||||
"""
|
||||
def set_available(merchant_id, available) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_available, available})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the store ID (when registered with channel).
|
||||
"""
|
||||
def set_store_id(merchant_id, store_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_store_id, store_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player to the blacklist.
|
||||
"""
|
||||
def add_to_blacklist(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:add_blacklist, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the blacklist.
|
||||
"""
|
||||
def remove_from_blacklist(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:remove_blacklist, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is in the blacklist.
|
||||
"""
|
||||
def is_blacklisted?(merchant_id, character_name) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:is_blacklisted, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor list (for owner view).
|
||||
"""
|
||||
def get_visitors(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_visitors)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the blacklist.
|
||||
"""
|
||||
def get_blacklist(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_blacklist)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets time remaining for the merchant (in seconds).
|
||||
"""
|
||||
def get_time_remaining(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_time_remaining)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current meso amount.
|
||||
"""
|
||||
def get_meso(merchant_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, :get_meso)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the meso amount.
|
||||
"""
|
||||
def set_meso(merchant_id, meso) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:set_meso, meso})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the merchant and saves items.
|
||||
"""
|
||||
def close_merchant(merchant_id, save_items \\ true, remove \\ true) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:close_merchant, save_items, remove})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character is the owner.
|
||||
"""
|
||||
def is_owner?(merchant_id, character_id) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all visitors.
|
||||
"""
|
||||
def broadcast_to_visitors(merchant_id, packet) do
|
||||
with {:ok, pid} <- lookup(merchant_id) do
|
||||
GenServer.cast(pid, {:broadcast, packet})
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
# Schedule expiration check
|
||||
schedule_expiration_check()
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, item}, _from, state) do
|
||||
new_items = state.items ++ [item]
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:buy_item, slot, quantity, _buyer_id, buyer_name}, _from, state) do
|
||||
cond do
|
||||
slot < 0 or slot >= length(state.items) ->
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
|
||||
true ->
|
||||
shop_item = Enum.at(state.items, slot)
|
||||
|
||||
cond do
|
||||
shop_item.bundles < quantity ->
|
||||
{:reply, {:error, :not_enough_stock}, state}
|
||||
|
||||
true ->
|
||||
price = shop_item.price * quantity
|
||||
|
||||
# Calculate tax (EntrustedStoreTax)
|
||||
tax = calculate_tax(price)
|
||||
net_price = price - tax
|
||||
|
||||
# Create bought item record
|
||||
bought_record = %{
|
||||
item_id: shop_item.item.item_id,
|
||||
quantity: quantity,
|
||||
total_price: price,
|
||||
buyer: buyer_name
|
||||
}
|
||||
|
||||
# Reduce bundles
|
||||
updated_item = ShopItem.reduce_bundles(shop_item, quantity)
|
||||
|
||||
# Update items list
|
||||
new_items =
|
||||
if ShopItem.sold_out?(updated_item) do
|
||||
List.delete_at(state.items, slot)
|
||||
else
|
||||
List.replace_at(state.items, slot, updated_item)
|
||||
end
|
||||
|
||||
# Create item for buyer
|
||||
buyer_item = ShopItem.create_buyer_item(shop_item, quantity)
|
||||
|
||||
# Update meso
|
||||
new_meso = state.meso + net_price
|
||||
|
||||
# Update state
|
||||
new_bought_items = [bought_record | state.bought_items]
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| items: new_items,
|
||||
meso: new_meso,
|
||||
bought_items: new_bought_items
|
||||
}
|
||||
|
||||
# Notify owner if online (simplified - would need world lookup)
|
||||
# Logger.info("Merchant item sold: #{shop_item.item.item_id} to #{buyer_name}")
|
||||
|
||||
{:reply, {:ok, buyer_item, price}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:search_item, item_id}, _from, state) do
|
||||
results =
|
||||
Enum.filter(state.items, fn shop_item ->
|
||||
shop_item.item.item_id == item_id and shop_item.bundles > 0
|
||||
end)
|
||||
|
||||
{:reply, results, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_name, character_pid}, _from, state) do
|
||||
# Check blacklist
|
||||
if character_name in state.blacklist do
|
||||
{:reply, {:error, :blacklisted}, state}
|
||||
else
|
||||
# Check if already visiting
|
||||
if Map.has_key?(state.visitors, character_id) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, {:ok, slot}, state}
|
||||
else
|
||||
# Find free slot
|
||||
case find_free_slot(state) do
|
||||
nil ->
|
||||
{:reply, {:error, :full}, state}
|
||||
|
||||
slot ->
|
||||
new_visitors =
|
||||
Map.put(state.visitors, character_id, %{
|
||||
pid: character_pid,
|
||||
slot: slot,
|
||||
name: character_name
|
||||
})
|
||||
|
||||
# Track visitor name for history
|
||||
new_visitor_names =
|
||||
if character_id != state.owner_id do
|
||||
[character_name | state.visitor_names]
|
||||
else
|
||||
state.visitor_names
|
||||
end
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| visitors: new_visitors,
|
||||
visitor_names: new_visitor_names
|
||||
}
|
||||
|
||||
{:reply, {:ok, slot}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_open, open}, _from, state) do
|
||||
{:reply, :ok, %{state | open: open}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_available, available}, _from, state) do
|
||||
{:reply, :ok, %{state | available: available}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_store_id, store_id}, _from, state) do
|
||||
{:reply, :ok, %{state | store_id: store_id}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_blacklist, character_name}, _from, state) do
|
||||
new_blacklist =
|
||||
if character_name in state.blacklist do
|
||||
state.blacklist
|
||||
else
|
||||
[character_name | state.blacklist]
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | blacklist: new_blacklist}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_blacklist, character_name}, _from, state) do
|
||||
new_blacklist = List.delete(state.blacklist, character_name)
|
||||
{:reply, :ok, %{state | blacklist: new_blacklist}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_blacklisted, character_name}, _from, state) do
|
||||
{:reply, character_name in state.blacklist, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_visitors, _from, state) do
|
||||
visitor_list = Enum.map(state.visitors, fn {_id, data} -> data.name end)
|
||||
{:reply, visitor_list, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_blacklist, _from, state) do
|
||||
{:reply, state.blacklist, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_time_remaining, _from, state) do
|
||||
elapsed = System.system_time(:millisecond) - state.start_time
|
||||
remaining = max(0, div(@merchant_duration - elapsed, 1000))
|
||||
{:reply, remaining, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_meso, _from, state) do
|
||||
{:reply, state.meso, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_meso, meso}, _from, state) do
|
||||
{:reply, :ok, %{state | meso: meso}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:close_merchant, save_items, _remove}, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:merchant_closed, state.id})
|
||||
end)
|
||||
|
||||
# Prepare items for saving (to Fredrick)
|
||||
items_to_save =
|
||||
if save_items do
|
||||
Enum.filter(state.items, fn item -> item.bundles > 0 end)
|
||||
|> Enum.map(fn shop_item ->
|
||||
item = shop_item.item
|
||||
total_qty = shop_item.bundles * item.quantity
|
||||
%{item | quantity: total_qty}
|
||||
end)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# Return unsold items and meso to owner
|
||||
{:reply, {:ok, items_to_save, state.meso}, %{state | open: false, available: false}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id}, _from, state) do
|
||||
{:reply, character_id == state.owner_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet}, state) do
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:merchant_packet, packet})
|
||||
end)
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_expiration, state) do
|
||||
elapsed = System.system_time(:millisecond) - state.start_time
|
||||
|
||||
if elapsed >= @merchant_duration do
|
||||
# Merchant has expired - close it
|
||||
Logger.info("Hired merchant #{state.id} has expired")
|
||||
|
||||
# Notify owner and save items
|
||||
# In full implementation, this would send to Fredrick
|
||||
|
||||
{:stop, :normal, state}
|
||||
else
|
||||
schedule_expiration_check()
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(merchant_id) do
|
||||
{:via, Registry, {Odinsea.MerchantRegistry, merchant_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp find_free_slot(state) do
|
||||
used_slots = Map.values(state.visitors) |> Enum.map(& &1.slot)
|
||||
|
||||
Enum.find(1..@max_visitors, fn slot ->
|
||||
slot not in used_slots
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_slot_for_character(state, character_id) do
|
||||
case Map.get(state.visitors, character_id) do
|
||||
nil -> -1
|
||||
data -> data.slot
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_tax(amount) do
|
||||
# Simple tax calculation - can be made more complex
|
||||
# Based on GameConstants.EntrustedStoreTax
|
||||
div(amount, 10)
|
||||
end
|
||||
|
||||
defp schedule_expiration_check do
|
||||
# Check every hour
|
||||
Process.send_after(self(), :check_expiration, 60 * 60 * 1000)
|
||||
end
|
||||
end
|
||||
@@ -16,12 +16,55 @@ defmodule Odinsea.Game.Map do
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Game.{MapFactory, LifeFactory, Monster, Reactor, ReactorFactory}
|
||||
alias Odinsea.Channel.Packets, as: ChannelPackets
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule SpawnPoint do
|
||||
@moduledoc "Represents a monster spawn point on the map"
|
||||
defstruct [
|
||||
:id,
|
||||
# Unique spawn point ID
|
||||
:mob_id,
|
||||
# Monster ID to spawn
|
||||
:x,
|
||||
# Spawn position X
|
||||
:y,
|
||||
# Spawn position Y
|
||||
:fh,
|
||||
# Foothold
|
||||
:cy,
|
||||
# CY value
|
||||
:f,
|
||||
# Facing direction (0 = left, 1 = right)
|
||||
:mob_time,
|
||||
# Respawn time in milliseconds
|
||||
:spawned_oid,
|
||||
# OID of currently spawned monster (nil if not spawned)
|
||||
:last_spawn_time,
|
||||
# Last time monster was spawned
|
||||
:respawn_timer_ref
|
||||
# Timer reference for respawn
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
mob_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
fh: integer(),
|
||||
cy: integer(),
|
||||
f: integer(),
|
||||
mob_time: integer(),
|
||||
spawned_oid: integer() | nil,
|
||||
last_spawn_time: DateTime.t() | nil,
|
||||
respawn_timer_ref: reference() | nil
|
||||
}
|
||||
end
|
||||
|
||||
defmodule State do
|
||||
@moduledoc "Map instance state"
|
||||
defstruct [
|
||||
@@ -32,22 +75,26 @@ defmodule Odinsea.Game.Map do
|
||||
:players,
|
||||
# Map stores character_id => %{oid: integer(), character: Character.State}
|
||||
:monsters,
|
||||
# Map stores oid => Monster
|
||||
# Map stores oid => Monster.t()
|
||||
:npcs,
|
||||
# Map stores oid => NPC
|
||||
:items,
|
||||
# Map stores oid => Item
|
||||
:reactors,
|
||||
# Map stores oid => Reactor
|
||||
:spawn_points,
|
||||
# Map stores spawn_id => SpawnPoint.t()
|
||||
# Object ID counter
|
||||
:next_oid,
|
||||
# Map properties (TODO: load from WZ data)
|
||||
# Map properties (loaded from MapFactory)
|
||||
:return_map,
|
||||
:forced_return,
|
||||
:time_limit,
|
||||
:field_limit,
|
||||
:mob_rate,
|
||||
:drop_rate,
|
||||
:map_name,
|
||||
:street_name,
|
||||
# Timestamps
|
||||
:created_at
|
||||
]
|
||||
@@ -56,10 +103,11 @@ defmodule Odinsea.Game.Map do
|
||||
map_id: non_neg_integer(),
|
||||
channel_id: byte(),
|
||||
players: %{pos_integer() => map()},
|
||||
monsters: %{pos_integer() => any()},
|
||||
monsters: %{pos_integer() => Monster.t()},
|
||||
npcs: %{pos_integer() => any()},
|
||||
items: %{pos_integer() => any()},
|
||||
reactors: %{pos_integer() => any()},
|
||||
spawn_points: %{integer() => SpawnPoint.t()},
|
||||
next_oid: pos_integer(),
|
||||
return_map: non_neg_integer() | nil,
|
||||
forced_return: non_neg_integer() | nil,
|
||||
@@ -67,6 +115,8 @@ defmodule Odinsea.Game.Map do
|
||||
field_limit: non_neg_integer() | nil,
|
||||
mob_rate: float(),
|
||||
drop_rate: float(),
|
||||
map_name: String.t() | nil,
|
||||
street_name: String.t() | nil,
|
||||
created_at: DateTime.t()
|
||||
}
|
||||
end
|
||||
@@ -152,6 +202,69 @@ defmodule Odinsea.Game.Map do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_players)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all monsters on the map.
|
||||
"""
|
||||
def get_monsters(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_monsters)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a monster at the specified spawn point.
|
||||
"""
|
||||
def spawn_monster(map_id, channel_id, spawn_id) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:spawn_monster, spawn_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles monster death and initiates respawn.
|
||||
"""
|
||||
def monster_killed(map_id, channel_id, oid, killer_id \\ nil) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:monster_killed, oid, killer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Damages a monster.
|
||||
"""
|
||||
def damage_monster(map_id, channel_id, oid, damage, character_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:damage_monster, oid, damage, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Hits a reactor, advancing its state and triggering effects.
|
||||
"""
|
||||
def hit_reactor(map_id, channel_id, oid, character_id, stance \\ 0) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:hit_reactor, oid, character_id, stance})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Destroys a reactor (e.g., after final state).
|
||||
"""
|
||||
def destroy_reactor(map_id, channel_id, oid) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:destroy_reactor, oid})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor by OID.
|
||||
"""
|
||||
def get_reactor(map_id, channel_id, oid) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:get_reactor, oid})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all reactors on the map.
|
||||
"""
|
||||
def get_reactors(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_reactors)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Respawns a destroyed reactor after its delay.
|
||||
"""
|
||||
def respawn_reactor(map_id, channel_id, original_reactor) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:respawn_reactor, original_reactor})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
@@ -161,6 +274,26 @@ defmodule Odinsea.Game.Map do
|
||||
map_id = Keyword.fetch!(opts, :map_id)
|
||||
channel_id = Keyword.fetch!(opts, :channel_id)
|
||||
|
||||
# Load map template from MapFactory
|
||||
template = MapFactory.get_template(map_id)
|
||||
|
||||
spawn_points =
|
||||
if template do
|
||||
load_spawn_points(template)
|
||||
else
|
||||
%{}
|
||||
end
|
||||
|
||||
# Load reactor spawns from template and create reactors
|
||||
reactors =
|
||||
if template do
|
||||
load_reactors(template, 500_000)
|
||||
else
|
||||
{%{}, 500_000}
|
||||
end
|
||||
|
||||
{reactor_map, next_oid} = reactors
|
||||
|
||||
state = %State{
|
||||
map_id: map_id,
|
||||
channel_id: channel_id,
|
||||
@@ -168,18 +301,27 @@ defmodule Odinsea.Game.Map do
|
||||
monsters: %{},
|
||||
npcs: %{},
|
||||
items: %{},
|
||||
reactors: %{},
|
||||
next_oid: 500_000,
|
||||
return_map: nil,
|
||||
forced_return: nil,
|
||||
time_limit: nil,
|
||||
field_limit: 0,
|
||||
mob_rate: 1.0,
|
||||
reactors: reactor_map,
|
||||
spawn_points: spawn_points,
|
||||
next_oid: next_oid,
|
||||
return_map: if(template, do: template.return_map, else: nil),
|
||||
forced_return: if(template, do: template.forced_return, else: nil),
|
||||
time_limit: if(template, do: template.time_limit, else: nil),
|
||||
field_limit: if(template, do: template.field_limit, else: 0),
|
||||
mob_rate: if(template, do: template.mob_rate, else: 1.0),
|
||||
drop_rate: 1.0,
|
||||
map_name: if(template, do: template.map_name, else: "Unknown"),
|
||||
street_name: if(template, do: template.street_name, else: ""),
|
||||
created_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
Logger.debug("Map loaded: #{map_id} (channel #{channel_id})")
|
||||
Logger.debug("Map loaded: #{map_id} (channel #{channel_id}) - #{map_size(spawn_points)} spawn points")
|
||||
|
||||
# Schedule initial monster spawning
|
||||
if map_size(spawn_points) > 0 do
|
||||
Process.send_after(self(), :spawn_initial_monsters, 100)
|
||||
end
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@@ -208,6 +350,8 @@ defmodule Odinsea.Game.Map do
|
||||
|
||||
if client_pid do
|
||||
send_existing_players(client_pid, new_players, except: character_id)
|
||||
send_existing_monsters(client_pid, state.monsters)
|
||||
send_existing_reactors(client_pid, state.reactors)
|
||||
end
|
||||
|
||||
new_state = %{
|
||||
@@ -247,6 +391,79 @@ defmodule Odinsea.Game.Map do
|
||||
{:reply, state.players, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_monsters, _from, state) do
|
||||
{:reply, state.monsters, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:damage_monster, oid, damage_amount, character_id}, _from, state) do
|
||||
case Map.get(state.monsters, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :monster_not_found}, state}
|
||||
|
||||
monster ->
|
||||
# Apply damage to monster
|
||||
case Monster.damage(monster, damage_amount, character_id) do
|
||||
{:dead, updated_monster, actual_damage} ->
|
||||
# Monster died
|
||||
Logger.debug("Monster #{oid} killed on map #{state.map_id}")
|
||||
|
||||
# Remove monster from map
|
||||
new_monsters = Map.delete(state.monsters, oid)
|
||||
|
||||
# Find spawn point
|
||||
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
||||
|
||||
# Update spawn point to clear spawned monster
|
||||
new_spawn_points =
|
||||
if spawn_point_id do
|
||||
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
||||
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
else
|
||||
state.spawn_points
|
||||
end
|
||||
|
||||
# Schedule respawn
|
||||
if spawn_point_id do
|
||||
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
||||
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
||||
end
|
||||
|
||||
# Broadcast monster death packet
|
||||
kill_packet = ChannelPackets.kill_monster(updated_monster, 1)
|
||||
broadcast_to_players(state.players, kill_packet)
|
||||
|
||||
Logger.debug("Monster killed: OID #{oid} on map #{state.map_id}")
|
||||
|
||||
# Calculate and distribute EXP
|
||||
distribute_exp(updated_monster, state.players, character_id)
|
||||
|
||||
# Create drops
|
||||
new_state =
|
||||
if not Monster.drops_disabled?(updated_monster) do
|
||||
create_monster_drops(updated_monster, character_id, state)
|
||||
else
|
||||
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
||||
end
|
||||
|
||||
{:reply, {:ok, :killed}, new_state}
|
||||
|
||||
{:ok, updated_monster, actual_damage} ->
|
||||
# Monster still alive
|
||||
new_monsters = Map.put(state.monsters, oid, updated_monster)
|
||||
new_state = %{state | monsters: new_monsters}
|
||||
|
||||
# Broadcast damage packet
|
||||
damage_packet = ChannelPackets.damage_monster(oid, actual_damage)
|
||||
broadcast_to_players(state.players, damage_packet)
|
||||
|
||||
{:reply, {:ok, :damaged}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet}, state) do
|
||||
broadcast_to_players(state.players, packet)
|
||||
@@ -259,6 +476,254 @@ defmodule Odinsea.Game.Map do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_call({:hit_reactor, oid, _character_id, stance}, _from, state) do
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :reactor_not_found}, state}
|
||||
|
||||
reactor ->
|
||||
if not reactor.alive do
|
||||
{:reply, {:error, :reactor_not_alive}, state}
|
||||
else
|
||||
# Advance reactor state
|
||||
old_state = reactor.state
|
||||
new_reactor = Reactor.advance_state(reactor)
|
||||
|
||||
# Check if reactor should be destroyed
|
||||
if Reactor.should_destroy?(new_reactor) do
|
||||
# Destroy reactor
|
||||
destroy_packet = ChannelPackets.destroy_reactor(new_reactor)
|
||||
broadcast_to_players(state.players, destroy_packet)
|
||||
|
||||
new_reactor = Reactor.set_alive(new_reactor, false)
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# Schedule respawn if delay is set
|
||||
if new_reactor.delay > 0 do
|
||||
schedule_reactor_respawn(oid, new_reactor.delay)
|
||||
end
|
||||
|
||||
{:reply, {:ok, :destroyed}, %{state | reactors: new_reactors}}
|
||||
else
|
||||
# Trigger state change
|
||||
trigger_packet = ChannelPackets.trigger_reactor(new_reactor, stance)
|
||||
broadcast_to_players(state.players, trigger_packet)
|
||||
|
||||
# Check for timeout and schedule if needed
|
||||
timeout = Reactor.get_timeout(new_reactor)
|
||||
new_reactor =
|
||||
if timeout > 0 do
|
||||
Reactor.set_timer_active(new_reactor, true)
|
||||
else
|
||||
new_reactor
|
||||
end
|
||||
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# If state changed, this might trigger a script
|
||||
script_trigger = old_state != new_reactor.state
|
||||
|
||||
{:reply, {:ok, %{state_changed: true, script_trigger: script_trigger}}, %{state | reactors: new_reactors}}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:destroy_reactor, oid}, _from, state) do
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :reactor_not_found}, state}
|
||||
|
||||
reactor ->
|
||||
# Broadcast destroy
|
||||
destroy_packet = ChannelPackets.destroy_reactor(reactor)
|
||||
broadcast_to_players(state.players, destroy_packet)
|
||||
|
||||
new_reactor =
|
||||
reactor
|
||||
|> Reactor.set_alive(false)
|
||||
|> Reactor.set_timer_active(false)
|
||||
|
||||
new_reactors = Map.put(state.reactors, oid, new_reactor)
|
||||
|
||||
# Schedule respawn if delay is set
|
||||
if reactor.delay > 0 do
|
||||
schedule_reactor_respawn(oid, reactor.delay)
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | reactors: new_reactors}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_reactor, oid}, _from, state) do
|
||||
{:reply, Map.get(state.reactors, oid), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_reactors, _from, state) do
|
||||
{:reply, state.reactors, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:respawn_reactor, original_reactor}, state) do
|
||||
# Create a fresh copy of the reactor
|
||||
respawned =
|
||||
original_reactor
|
||||
|> Reactor.copy()
|
||||
|> Reactor.set_oid(state.next_oid)
|
||||
|
||||
new_reactors = Map.put(state.reactors, state.next_oid, respawned)
|
||||
|
||||
# Broadcast spawn
|
||||
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
||||
|
||||
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def handle_cast({:spawn_monster, spawn_id}, state) do
|
||||
case Map.get(state.spawn_points, spawn_id) do
|
||||
nil ->
|
||||
Logger.warn("Spawn point #{spawn_id} not found on map #{state.map_id}")
|
||||
{:noreply, state}
|
||||
|
||||
spawn_point ->
|
||||
if spawn_point.spawned_oid do
|
||||
# Already spawned
|
||||
{:noreply, state}
|
||||
else
|
||||
# Spawn new monster
|
||||
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:monster_killed, oid, killer_id}, state) do
|
||||
# Handle monster death (called externally)
|
||||
case Map.get(state.monsters, oid) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
monster ->
|
||||
# Remove monster
|
||||
new_monsters = Map.delete(state.monsters, oid)
|
||||
|
||||
# Find and update spawn point
|
||||
spawn_point_id = find_spawn_point_by_oid(state.spawn_points, oid)
|
||||
|
||||
new_spawn_points =
|
||||
if spawn_point_id do
|
||||
update_spawn_point(state.spawn_points, spawn_point_id, fn sp ->
|
||||
%{sp | spawned_oid: nil, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
else
|
||||
state.spawn_points
|
||||
end
|
||||
|
||||
# Schedule respawn
|
||||
if spawn_point_id do
|
||||
spawn_point = Map.get(new_spawn_points, spawn_point_id)
|
||||
schedule_respawn(spawn_point_id, spawn_point.mob_time)
|
||||
end
|
||||
|
||||
# Create drops if killer_id is provided
|
||||
new_state =
|
||||
if killer_id && not Monster.drops_disabled?(monster) do
|
||||
monster_with_stats = %{monster | attackers: %{}} # Reset attackers since this is external
|
||||
create_monster_drops(monster, killer_id, %{state |
|
||||
monsters: new_monsters,
|
||||
spawn_points: new_spawn_points
|
||||
})
|
||||
else
|
||||
%{state | monsters: new_monsters, spawn_points: new_spawn_points}
|
||||
end
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:spawn_initial_monsters, state) do
|
||||
Logger.debug("Spawning initial monsters on map #{state.map_id}")
|
||||
|
||||
# Spawn all monsters at their spawn points
|
||||
new_state =
|
||||
Enum.reduce(state.spawn_points, state, fn {spawn_id, spawn_point}, acc_state ->
|
||||
{updated_state, _oid} = do_spawn_monster(acc_state, spawn_point, spawn_id)
|
||||
updated_state
|
||||
end)
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:respawn_monster, spawn_id}, state) do
|
||||
# Respawn monster at spawn point
|
||||
case Map.get(state.spawn_points, spawn_id) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
spawn_point ->
|
||||
if spawn_point.spawned_oid do
|
||||
# Already spawned (shouldn't happen)
|
||||
{:noreply, state}
|
||||
else
|
||||
{new_state, _oid} = do_spawn_monster(state, spawn_point, spawn_id)
|
||||
{:noreply, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:respawn_reactor, oid}, state) do
|
||||
# Respawn a destroyed reactor
|
||||
case Map.get(state.reactors, oid) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
original_reactor ->
|
||||
if original_reactor.alive do
|
||||
# Already alive (shouldn't happen)
|
||||
{:noreply, state}
|
||||
else
|
||||
# Create a fresh copy
|
||||
respawned =
|
||||
original_reactor
|
||||
|> Reactor.copy()
|
||||
|> Reactor.set_oid(state.next_oid)
|
||||
|
||||
new_reactors =
|
||||
state.reactors
|
||||
|> Map.delete(oid) # Remove old destroyed reactor
|
||||
|> Map.put(state.next_oid, respawned)
|
||||
|
||||
# Broadcast spawn to players
|
||||
spawn_packet = ChannelPackets.spawn_reactor(respawned)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Reactor #{respawned.reactor_id} respawned on map #{state.map_id} with OID #{state.next_oid}")
|
||||
|
||||
{:noreply, %{state | reactors: new_reactors, next_oid: state.next_oid + 1}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
@@ -294,7 +759,390 @@ defmodule Odinsea.Game.Map do
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_existing_monsters(client_pid, monsters) do
|
||||
Enum.each(monsters, fn {_oid, monster} ->
|
||||
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
||||
send_packet(client_pid, spawn_packet)
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_existing_reactors(client_pid, reactors) do
|
||||
Enum.each(reactors, fn {_oid, reactor} ->
|
||||
spawn_packet = ChannelPackets.spawn_reactor(reactor)
|
||||
send_packet(client_pid, spawn_packet)
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_packet(client_pid, packet) do
|
||||
send(client_pid, {:send_packet, packet})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Spawning Helpers
|
||||
# ============================================================================
|
||||
|
||||
defp load_spawn_points(template) do
|
||||
# Load spawn points from template
|
||||
template.spawn_points
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {sp, idx} ->
|
||||
spawn_point = %SpawnPoint{
|
||||
id: idx,
|
||||
mob_id: sp.mob_id,
|
||||
x: sp.x,
|
||||
y: sp.y,
|
||||
fh: sp.fh,
|
||||
cy: sp.cy,
|
||||
f: sp.f || 0,
|
||||
mob_time: sp.mob_time || 10_000,
|
||||
# Default 10 seconds
|
||||
spawned_oid: nil,
|
||||
last_spawn_time: nil,
|
||||
respawn_timer_ref: nil
|
||||
}
|
||||
|
||||
{idx, spawn_point}
|
||||
end)
|
||||
|> Map.new()
|
||||
rescue
|
||||
_e ->
|
||||
Logger.warn("Failed to load spawn points for map, using empty spawn list")
|
||||
%{}
|
||||
end
|
||||
|
||||
defp load_reactors(template, starting_oid) do
|
||||
# Load reactors from template reactor spawns
|
||||
{reactor_map, next_oid} =
|
||||
template.reactor_spawns
|
||||
|> Enum.with_index(starting_oid)
|
||||
|> Enum.reduce({%{}, starting_oid}, fn {rs, oid}, {acc_map, _acc_oid} ->
|
||||
case ReactorFactory.create_reactor(
|
||||
rs.reactor_id,
|
||||
rs.x,
|
||||
rs.y,
|
||||
rs.facing_direction,
|
||||
rs.name,
|
||||
rs.delay
|
||||
) do
|
||||
nil ->
|
||||
# Reactor stats not found, skip
|
||||
{acc_map, oid}
|
||||
|
||||
reactor ->
|
||||
# Assign OID to reactor
|
||||
reactor = Reactor.set_oid(reactor, oid)
|
||||
{Map.put(acc_map, oid, reactor), oid + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
count = map_size(reactor_map)
|
||||
if count > 0 do
|
||||
Logger.debug("Loaded #{count} reactors on map #{template.map_id}")
|
||||
end
|
||||
|
||||
{reactor_map, next_oid}
|
||||
rescue
|
||||
_e ->
|
||||
Logger.warn("Failed to load reactors for map, using empty reactor list")
|
||||
{%{}, starting_oid}
|
||||
end
|
||||
|
||||
defp do_spawn_monster(state, spawn_point, spawn_id) do
|
||||
# Get monster stats from LifeFactory
|
||||
case LifeFactory.get_monster_stats(spawn_point.mob_id) do
|
||||
nil ->
|
||||
Logger.warn("Monster stats not found for mob_id #{spawn_point.mob_id}")
|
||||
{state, nil}
|
||||
|
||||
stats ->
|
||||
# Allocate OID
|
||||
oid = state.next_oid
|
||||
|
||||
# Create monster instance
|
||||
position = %{x: spawn_point.x, y: spawn_point.y, fh: spawn_point.fh}
|
||||
|
||||
monster = %Monster{
|
||||
oid: oid,
|
||||
mob_id: spawn_point.mob_id,
|
||||
stats: stats,
|
||||
hp: stats.hp,
|
||||
mp: stats.mp,
|
||||
max_hp: stats.hp,
|
||||
max_mp: stats.mp,
|
||||
position: position,
|
||||
stance: 5,
|
||||
# Default stance
|
||||
controller_id: nil,
|
||||
controller_has_aggro: false,
|
||||
spawn_effect: 0,
|
||||
team: -1,
|
||||
fake: false,
|
||||
link_oid: 0,
|
||||
status_effects: %{},
|
||||
poisons: [],
|
||||
attackers: %{},
|
||||
last_attack: System.system_time(:millisecond),
|
||||
last_move: System.system_time(:millisecond),
|
||||
last_skill_use: 0,
|
||||
killed: false,
|
||||
drops_disabled: false,
|
||||
create_time: System.system_time(:millisecond)
|
||||
}
|
||||
|
||||
# Add to monsters map
|
||||
new_monsters = Map.put(state.monsters, oid, monster)
|
||||
|
||||
# Update spawn point
|
||||
new_spawn_points =
|
||||
update_spawn_point(state.spawn_points, spawn_id, fn sp ->
|
||||
%{sp | spawned_oid: oid, last_spawn_time: DateTime.utc_now()}
|
||||
end)
|
||||
|
||||
# Broadcast monster spawn packet to all players
|
||||
spawn_packet = ChannelPackets.spawn_monster(monster, -1, 0)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
|
||||
Logger.debug("Spawned monster #{monster.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| monsters: new_monsters,
|
||||
spawn_points: new_spawn_points,
|
||||
next_oid: oid + 1
|
||||
}
|
||||
|
||||
Logger.debug("Spawned monster #{spawn_point.mob_id} (OID: #{oid}) on map #{state.map_id}")
|
||||
|
||||
{new_state, oid}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_spawn_point_by_oid(spawn_points, oid) do
|
||||
Enum.find_value(spawn_points, fn {spawn_id, sp} ->
|
||||
if sp.spawned_oid == oid, do: spawn_id, else: nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_spawn_point(spawn_points, spawn_id, update_fn) do
|
||||
case Map.get(spawn_points, spawn_id) do
|
||||
nil -> spawn_points
|
||||
sp -> Map.put(spawn_points, spawn_id, update_fn.(sp))
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_respawn(spawn_id, mob_time) do
|
||||
# Schedule respawn message
|
||||
Process.send_after(self(), {:respawn_monster, spawn_id}, mob_time)
|
||||
end
|
||||
|
||||
defp schedule_reactor_respawn(oid, delay) do
|
||||
# Schedule reactor respawn message
|
||||
Process.send_after(self(), {:respawn_reactor, oid}, delay)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# EXP Distribution
|
||||
# ============================================================================
|
||||
|
||||
defp distribute_exp(monster, players, _killer_id) do
|
||||
# Calculate base EXP from monster
|
||||
base_exp = calculate_monster_exp(monster)
|
||||
|
||||
if base_exp > 0 do
|
||||
# Find highest damage dealer
|
||||
{highest_attacker_id, _highest_damage} =
|
||||
Enum.max_by(
|
||||
monster.attackers,
|
||||
fn {_id, entry} -> entry.damage end,
|
||||
fn -> {nil, 0} end
|
||||
)
|
||||
|
||||
# Distribute EXP to all attackers
|
||||
Enum.each(monster.attackers, fn {attacker_id, attacker_data} ->
|
||||
# Calculate EXP share based on damage dealt
|
||||
damage_ratio = attacker_data.damage / max(1, monster.max_hp)
|
||||
attacker_exp = trunc(base_exp * min(1.0, damage_ratio))
|
||||
|
||||
is_highest = attacker_id == highest_attacker_id
|
||||
|
||||
# Find character and give EXP
|
||||
case find_character_pid(attacker_id) do
|
||||
{:ok, character_pid} ->
|
||||
give_exp_to_character(character_pid, attacker_exp, is_highest, monster)
|
||||
|
||||
{:error, _} ->
|
||||
Logger.debug("Character #{attacker_id} not found for EXP distribution")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_monster_exp(monster) do
|
||||
# Base EXP from monster stats
|
||||
base = monster.stats.exp
|
||||
|
||||
# Apply any multipliers
|
||||
# TODO: Add event multipliers, premium account bonuses, etc.
|
||||
base
|
||||
end
|
||||
|
||||
defp give_exp_to_character(character_pid, exp_amount, is_highest, monster) do
|
||||
# TODO: Apply EXP buffs (Holy Symbol, exp cards, etc.)
|
||||
# TODO: Apply level difference penalties
|
||||
# TODO: Apply server rates
|
||||
|
||||
final_exp = exp_amount
|
||||
|
||||
# Give EXP to character
|
||||
case Character.gain_exp(character_pid, final_exp, is_highest) do
|
||||
:ok ->
|
||||
Logger.debug("Gave #{final_exp} EXP to character (highest: #{is_highest})")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to give EXP to character: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp find_character_pid(character_id) do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Drop System
|
||||
# ============================================================================
|
||||
|
||||
defp create_monster_drops(monster, killer_id, state) do
|
||||
# Get monster position
|
||||
position = monster.position
|
||||
|
||||
# Calculate drop rate multiplier (from map/server rates)
|
||||
drop_rate_multiplier = state.drop_rate
|
||||
|
||||
# Create drops
|
||||
drops = DropSystem.create_monster_drops(
|
||||
monster.mob_id,
|
||||
killer_id,
|
||||
position,
|
||||
state.next_oid,
|
||||
drop_rate_multiplier
|
||||
)
|
||||
|
||||
# Also create global drops
|
||||
global_drops = DropSystem.create_global_drops(
|
||||
killer_id,
|
||||
position,
|
||||
state.next_oid + length(drops),
|
||||
drop_rate_multiplier
|
||||
)
|
||||
|
||||
all_drops = drops ++ global_drops
|
||||
|
||||
if length(all_drops) > 0 do
|
||||
# Add drops to map state
|
||||
new_items =
|
||||
Enum.reduce(all_drops, state.items, fn drop, items ->
|
||||
Map.put(items, drop.oid, drop)
|
||||
end)
|
||||
|
||||
# Broadcast drop spawn packets
|
||||
Enum.each(all_drops, fn drop ->
|
||||
spawn_packet = ChannelPackets.spawn_drop(drop, position, 1)
|
||||
broadcast_to_players(state.players, spawn_packet)
|
||||
end)
|
||||
|
||||
# Update next OID
|
||||
next_oid = state.next_oid + length(all_drops)
|
||||
|
||||
Logger.debug("Created #{length(all_drops)} drops on map #{state.map_id}")
|
||||
|
||||
%{state | items: new_items, next_oid: next_oid}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all drops on the map.
|
||||
"""
|
||||
def get_drops(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_drops)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to pick up a drop.
|
||||
"""
|
||||
def pickup_drop(map_id, channel_id, drop_oid, character_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:pickup_drop, drop_oid, character_id})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_drops, _from, state) do
|
||||
{:reply, state.items, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:pickup_drop, drop_oid, character_id}, _from, state) do
|
||||
case Map.get(state.items, drop_oid) do
|
||||
nil ->
|
||||
{:reply, {:error, :drop_not_found}, state}
|
||||
|
||||
drop ->
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
case DropSystem.pickup_drop(drop, character_id, now) do
|
||||
{:ok, updated_drop} ->
|
||||
# Broadcast pickup animation
|
||||
remove_packet = ChannelPackets.remove_drop(drop_oid, 2, character_id)
|
||||
broadcast_to_players(state.players, remove_packet)
|
||||
|
||||
# Remove from map
|
||||
new_items = Map.delete(state.items, drop_oid)
|
||||
|
||||
# Return drop info for inventory addition
|
||||
{:reply, {:ok, updated_drop}, %{state | items: new_items}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_drop_expiration, state) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Check for expired drops
|
||||
{expired_drops, valid_drops} =
|
||||
Enum.split_with(state.items, fn {_oid, drop} ->
|
||||
Drop.should_expire?(drop, now)
|
||||
end)
|
||||
|
||||
# Broadcast expiration for expired drops
|
||||
Enum.each(expired_drops, fn {oid, _drop} ->
|
||||
expire_packet = ChannelPackets.remove_drop(oid, 0, 0)
|
||||
broadcast_to_players(state.players, expire_packet)
|
||||
end)
|
||||
|
||||
# Convert valid drops back to map
|
||||
new_items = Map.new(valid_drops)
|
||||
|
||||
# Schedule next check if there are drops remaining
|
||||
if map_size(new_items) > 0 do
|
||||
Process.send_after(self(), :check_drop_expiration, 10_000)
|
||||
end
|
||||
|
||||
{:noreply, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
defp send_existing_items(client_pid, items) do
|
||||
Enum.each(items, fn {_oid, drop} ->
|
||||
if not drop.picked_up do
|
||||
packet = ChannelPackets.spawn_drop(drop, nil, 2)
|
||||
send_packet(client_pid, packet)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -127,6 +127,52 @@ defmodule Odinsea.Game.MapFactory do
|
||||
]
|
||||
end
|
||||
|
||||
defmodule SpawnPoint do
|
||||
@moduledoc "Represents a monster spawn point on a map"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
mob_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
fh: integer(),
|
||||
cy: integer(),
|
||||
f: integer(),
|
||||
mob_time: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:mob_id,
|
||||
:x,
|
||||
:y,
|
||||
:fh,
|
||||
:cy,
|
||||
:f,
|
||||
:mob_time
|
||||
]
|
||||
end
|
||||
|
||||
defmodule ReactorSpawn do
|
||||
@moduledoc "Represents a reactor spawn point on a map"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
reactor_id: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
facing_direction: integer(),
|
||||
name: String.t(),
|
||||
delay: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:reactor_id,
|
||||
:x,
|
||||
:y,
|
||||
facing_direction: 0,
|
||||
name: "",
|
||||
delay: 0
|
||||
]
|
||||
end
|
||||
|
||||
defmodule FieldTemplate do
|
||||
@moduledoc "Map field template containing all map data"
|
||||
|
||||
@@ -143,7 +189,8 @@ defmodule Odinsea.Game.MapFactory do
|
||||
dec_hp_interval: integer(),
|
||||
portal_map: %{String.t() => Portal.t()},
|
||||
portals: [Portal.t()],
|
||||
spawn_points: [Portal.t()],
|
||||
spawn_points: [SpawnPoint.t()],
|
||||
reactor_spawns: [ReactorSpawn.t()],
|
||||
footholds: [Foothold.t()],
|
||||
top: integer(),
|
||||
bottom: integer(),
|
||||
@@ -175,6 +222,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
:portal_map,
|
||||
:portals,
|
||||
:spawn_points,
|
||||
:reactor_spawns,
|
||||
:footholds,
|
||||
:top,
|
||||
:bottom,
|
||||
@@ -341,10 +389,15 @@ defmodule Odinsea.Game.MapFactory do
|
||||
|> Enum.map(fn portal -> {portal.name, portal} end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
# Parse spawn points
|
||||
spawn_points =
|
||||
Enum.filter(portals, fn portal ->
|
||||
portal.type == :spawn || portal.name == "sp"
|
||||
end)
|
||||
(map_data[:spawns] || [])
|
||||
|> Enum.map(&build_spawn_point/1)
|
||||
|
||||
# Parse reactor spawns
|
||||
reactor_spawns =
|
||||
(map_data[:reactors] || [])
|
||||
|> Enum.map(&build_reactor_spawn/1)
|
||||
|
||||
# Parse footholds
|
||||
footholds =
|
||||
@@ -365,6 +418,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
portal_map: portal_map,
|
||||
portals: portals,
|
||||
spawn_points: spawn_points,
|
||||
reactor_spawns: reactor_spawns,
|
||||
footholds: footholds,
|
||||
top: map_data[:top] || 0,
|
||||
bottom: map_data[:bottom] || 0,
|
||||
@@ -415,6 +469,29 @@ defmodule Odinsea.Game.MapFactory do
|
||||
}
|
||||
end
|
||||
|
||||
defp build_spawn_point(spawn_data) do
|
||||
%SpawnPoint{
|
||||
mob_id: spawn_data[:mob_id] || 0,
|
||||
x: spawn_data[:x] || 0,
|
||||
y: spawn_data[:y] || 0,
|
||||
fh: spawn_data[:fh] || 0,
|
||||
cy: spawn_data[:cy] || 0,
|
||||
f: spawn_data[:f] || 0,
|
||||
mob_time: spawn_data[:mob_time] || 10_000
|
||||
}
|
||||
end
|
||||
|
||||
defp build_reactor_spawn(reactor_data) do
|
||||
%ReactorSpawn{
|
||||
reactor_id: reactor_data[:reactor_id] || reactor_data[:id] || 0,
|
||||
x: reactor_data[:x] || 0,
|
||||
y: reactor_data[:y] || 0,
|
||||
facing_direction: reactor_data[:f] || reactor_data[:facing_direction] || 0,
|
||||
name: reactor_data[:name] || "",
|
||||
delay: reactor_data[:reactor_time] || reactor_data[:delay] || 0
|
||||
}
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_maps do
|
||||
# Common beginner maps
|
||||
@@ -441,7 +518,7 @@ defmodule Odinsea.Game.MapFactory do
|
||||
%{id: 0, name: "sp", type: "sp", x: -1283, y: 86, target_map: 100000000, target_portal: ""}
|
||||
]
|
||||
},
|
||||
# Henesys Hunting Ground I
|
||||
# Henesys Hunting Ground I - with monsters!
|
||||
%{
|
||||
map_id: 100010000,
|
||||
map_name: "Henesys Hunting Ground I",
|
||||
@@ -450,6 +527,15 @@ defmodule Odinsea.Game.MapFactory do
|
||||
forced_return: 100000000,
|
||||
portals: [
|
||||
%{id: 0, name: "sp", type: "sp", x: 0, y: 0, target_map: 100010000, target_portal: ""}
|
||||
],
|
||||
spawns: [
|
||||
# Blue Snails (mob_id: 100001)
|
||||
%{mob_id: 100001, x: -500, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
|
||||
%{mob_id: 100001, x: -200, y: 100, fh: 0, cy: 0, f: 1, mob_time: 8000},
|
||||
%{mob_id: 100001, x: 200, y: 100, fh: 0, cy: 0, f: 0, mob_time: 8000},
|
||||
# Orange Mushrooms (mob_id: 1210102)
|
||||
%{mob_id: 1210102, x: 500, y: 100, fh: 0, cy: 0, f: 1, mob_time: 10000},
|
||||
%{mob_id: 1210102, x: 800, y: 100, fh: 0, cy: 0, f: 0, mob_time: 10000}
|
||||
]
|
||||
},
|
||||
# Hidden Street - FM Entrance
|
||||
|
||||
645
lib/odinsea/game/mini_game.ex
Normal file
645
lib/odinsea/game/mini_game.ex
Normal file
@@ -0,0 +1,645 @@
|
||||
defmodule Odinsea.Game.MiniGame do
|
||||
@moduledoc """
|
||||
Mini Game system for Omok and Match Card games in player shops.
|
||||
Ported from src/server/shops/MapleMiniGame.java
|
||||
|
||||
Mini games allow players to:
|
||||
- Play Omok (5-in-a-row)
|
||||
- Play Match Card (memory game)
|
||||
- Track wins/losses/ties
|
||||
- Earn game points
|
||||
|
||||
Game Types:
|
||||
- 1 = Omok (5-in-a-row)
|
||||
- 2 = Match Card (memory matching)
|
||||
|
||||
Game lifecycle:
|
||||
1. Owner creates game with type and description
|
||||
2. Visitor joins and both mark ready
|
||||
3. Game starts and players take turns
|
||||
4. Game ends with win/loss/tie
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
# Game type constants
|
||||
@game_type_omok 1
|
||||
@game_type_match_card 2
|
||||
|
||||
# Shop type constants (from IMaplePlayerShop)
|
||||
@shop_type_omok 3
|
||||
@shop_type_match_card 4
|
||||
|
||||
# Board size for Omok
|
||||
@omok_board_size 15
|
||||
|
||||
# Default slots for mini games
|
||||
@max_slots 2
|
||||
|
||||
# Struct for the mini game state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:password,
|
||||
:game_type,
|
||||
:piece_type,
|
||||
:map_id,
|
||||
:channel,
|
||||
:visitors,
|
||||
:ready,
|
||||
:points,
|
||||
:exit_after,
|
||||
:open,
|
||||
:available,
|
||||
# Omok specific
|
||||
:board,
|
||||
:loser,
|
||||
:turn,
|
||||
# Match card specific
|
||||
:match_cards,
|
||||
:first_slot,
|
||||
:tie_requested
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new mini game GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
game_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(game_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new mini game.
|
||||
"""
|
||||
def create(opts) do
|
||||
game_type = opts[:game_type] || @game_type_omok
|
||||
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
password: opts[:password] || "",
|
||||
game_type: game_type,
|
||||
piece_type: opts[:piece_type] || 0,
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
visitors: %{},
|
||||
ready: {false, false},
|
||||
points: {0, 0},
|
||||
exit_after: {false, false},
|
||||
open: true,
|
||||
available: true,
|
||||
# Omok board (15x15 grid)
|
||||
board: create_empty_board(),
|
||||
loser: 0,
|
||||
turn: 1,
|
||||
# Match card
|
||||
match_cards: [],
|
||||
first_slot: 0,
|
||||
tie_requested: -1
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type for this game.
|
||||
"""
|
||||
def shop_type(%__MODULE__{game_type: type}) do
|
||||
case type do
|
||||
@game_type_omok -> @shop_type_omok
|
||||
@game_type_match_card -> @shop_type_match_card
|
||||
_ -> @shop_type_omok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the game type constant.
|
||||
"""
|
||||
def game_type_omok, do: @game_type_omok
|
||||
def game_type_match_card, do: @game_type_match_card
|
||||
|
||||
@doc """
|
||||
Gets the current game state.
|
||||
"""
|
||||
def get_state(game_pid) when is_pid(game_pid) do
|
||||
GenServer.call(game_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(game_id) do
|
||||
case lookup(game_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a game by ID.
|
||||
"""
|
||||
def lookup(game_id) do
|
||||
case Registry.lookup(Odinsea.MiniGameRegistry, game_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the game.
|
||||
Returns the visitor slot or {:error, reason}.
|
||||
"""
|
||||
def add_visitor(game_id, character_id, character_pid) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the game.
|
||||
"""
|
||||
def remove_visitor(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a player as ready/not ready.
|
||||
"""
|
||||
def set_ready(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:set_ready, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is ready.
|
||||
"""
|
||||
def is_ready?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_ready, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the game (if all players ready).
|
||||
"""
|
||||
def start_game(game_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, :start_game)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Makes an Omok move.
|
||||
"""
|
||||
def make_omok_move(game_id, character_id, x, y, piece_type) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:omok_move, character_id, x, y, piece_type})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Selects a card in Match Card game.
|
||||
"""
|
||||
def select_card(game_id, character_id, slot) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:select_card, character_id, slot})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Requests a tie.
|
||||
"""
|
||||
def request_tie(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:request_tie, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Answers a tie request.
|
||||
"""
|
||||
def answer_tie(game_id, character_id, accept) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:answer_tie, character_id, accept})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Skips turn (forfeits move).
|
||||
"""
|
||||
def skip_turn(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:skip_turn, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gives up (forfeits game).
|
||||
"""
|
||||
def give_up(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:give_up, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets exit after game flag.
|
||||
"""
|
||||
def set_exit_after(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:set_exit_after, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if player wants to exit after game.
|
||||
"""
|
||||
def is_exit_after?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_exit_after, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor slot for a character.
|
||||
"""
|
||||
def get_visitor_slot(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:get_visitor_slot, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if character is the owner.
|
||||
"""
|
||||
def is_owner?(game_id, character_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the game.
|
||||
"""
|
||||
def close_game(game_id) do
|
||||
with {:ok, pid} <- lookup(game_id) do
|
||||
GenServer.call(pid, :close_game)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of matches needed to win.
|
||||
"""
|
||||
def get_matches_to_win(piece_type) do
|
||||
case piece_type do
|
||||
0 -> 6
|
||||
1 -> 10
|
||||
2 -> 15
|
||||
_ -> 6
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
|
||||
visitor_count = map_size(state.visitors)
|
||||
|
||||
if visitor_count >= @max_slots - 1 do
|
||||
{:reply, {:error, :full}, state}
|
||||
else
|
||||
slot = visitor_count + 1
|
||||
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
|
||||
{:reply, {:ok, slot}, %{state | visitors: new_visitors}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_ready, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot > 0 do
|
||||
{r0, r1} = state.ready
|
||||
new_ready = if slot == 1, do: {not r0, r1}, else: {r0, not r1}
|
||||
{:reply, :ok, %{state | ready: new_ready}}
|
||||
else
|
||||
{:reply, {:error, :not_visitor}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_ready, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{r0, r1} = state.ready
|
||||
ready = if slot == 1, do: r0, else: r1
|
||||
{:reply, ready, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:start_game, _from, state) do
|
||||
{r0, r1} = state.ready
|
||||
|
||||
if r0 and r1 do
|
||||
# Initialize game based on type
|
||||
new_state =
|
||||
case state.game_type do
|
||||
@game_type_omok ->
|
||||
%{state | board: create_empty_board(), open: false}
|
||||
|
||||
@game_type_match_card ->
|
||||
cards = generate_match_cards(state.piece_type)
|
||||
%{state | match_cards: cards, open: false}
|
||||
|
||||
_ ->
|
||||
%{state | open: false}
|
||||
end
|
||||
|
||||
{:reply, {:ok, new_state.loser}, new_state}
|
||||
else
|
||||
{:reply, {:error, :not_ready}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:omok_move, character_id, x, y, piece_type}, _from, state) do
|
||||
# Check if it's this player's turn (loser goes first)
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot != state.loser + 1 do
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
else
|
||||
# Check if position is valid and empty
|
||||
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
||||
{:reply, {:error, :invalid_position}, state}
|
||||
else
|
||||
current_piece = get_board_piece(state.board, x, y)
|
||||
|
||||
if current_piece != 0 do
|
||||
{:reply, {:error, :position_occupied}, state}
|
||||
else
|
||||
# Place piece
|
||||
new_board = set_board_piece(state.board, x, y, piece_type)
|
||||
|
||||
# Check for win
|
||||
won = check_omok_win(new_board, x, y, piece_type)
|
||||
|
||||
# Next turn
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| board: new_board,
|
||||
loser: next_loser
|
||||
}
|
||||
|
||||
if won do
|
||||
# Award point
|
||||
{p0, p1} = state.points
|
||||
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
||||
{:reply, {:win, slot}, %{new_state | points: new_points, open: true}}
|
||||
else
|
||||
{:reply, {:ok, won}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:select_card, character_id, slot}, _from, state) do
|
||||
# Match card logic
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot != state.loser + 1 do
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
else
|
||||
# Simplified match card logic
|
||||
# In full implementation, track first/second card selection and matching
|
||||
|
||||
turn = state.turn
|
||||
|
||||
if turn == 1 do
|
||||
# First card
|
||||
{:reply, {:first_card, slot}, %{state | first_slot: slot, turn: 0}}
|
||||
else
|
||||
# Second card - check match
|
||||
first_card = Enum.at(state.match_cards, state.first_slot - 1)
|
||||
second_card = Enum.at(state.match_cards, slot - 1)
|
||||
|
||||
if first_card == second_card do
|
||||
# Match! Award point
|
||||
{p0, p1} = state.points
|
||||
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
||||
|
||||
# Check for game win
|
||||
{p0_new, p1_new} = new_points
|
||||
matches_needed = get_matches_to_win(state.piece_type)
|
||||
|
||||
if p0_new >= matches_needed or p1_new >= matches_needed do
|
||||
{:reply, {:game_win, slot}, %{state | points: new_points, turn: 1, open: true}}
|
||||
else
|
||||
{:reply, {:match, slot}, %{state | points: new_points, turn: 1}}
|
||||
end
|
||||
else
|
||||
# No match, switch turns
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
{:reply, {:no_match, slot}, %{state | turn: 1, loser: next_loser}}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:request_tie, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if state.tie_requested == -1 do
|
||||
{:reply, :ok, %{state | tie_requested: slot}}
|
||||
else
|
||||
{:reply, {:error, :already_requested}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:answer_tie, character_id, accept}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if state.tie_requested != -1 and state.tie_requested != slot do
|
||||
if accept do
|
||||
# Tie accepted
|
||||
{p0, p1} = state.points
|
||||
{:reply, {:tie, slot}, %{state | tie_requested: -1, points: {p0, p1}, open: true}}
|
||||
else
|
||||
{:reply, {:deny, slot}, %{state | tie_requested: -1}}
|
||||
end
|
||||
else
|
||||
{:reply, {:error, :invalid_request}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:skip_turn, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot == state.loser + 1 do
|
||||
next_loser = rem(state.loser + 1, @max_slots)
|
||||
{:reply, :ok, %{state | loser: next_loser}}
|
||||
else
|
||||
{:reply, {:error, :not_your_turn}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:give_up, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
# Other player wins
|
||||
winner = if slot == 1, do: 2, else: 1
|
||||
{:reply, {:give_up, winner}, %{state | open: true}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_exit_after, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
|
||||
if slot > 0 do
|
||||
{e0, e1} = state.exit_after
|
||||
new_exit = if slot == 1, do: {not e0, e1}, else: {e0, not e1}
|
||||
{:reply, :ok, %{state | exit_after: new_exit}}
|
||||
else
|
||||
{:reply, {:error, :not_visitor}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_exit_after, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{e0, e1} = state.exit_after
|
||||
exit = if slot == 1, do: e0, else: e1
|
||||
{:reply, exit, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_visitor_slot, character_id}, _from, state) do
|
||||
slot = get_slot_for_character_internal(state, character_id)
|
||||
{:reply, slot, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id}, _from, state) do
|
||||
{:reply, character_id == state.owner_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:close_game, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:game_closed, state.id})
|
||||
end)
|
||||
|
||||
{:stop, :normal, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(game_id) do
|
||||
{:via, Registry, {Odinsea.MiniGameRegistry, game_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp get_slot_for_character_internal(state, character_id) do
|
||||
cond do
|
||||
character_id == state.owner_id -> 1
|
||||
true -> Map.get(state.visitors, character_id, %{}) |> Map.get(:slot, -1)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_empty_board do
|
||||
for _ <- 1..@omok_board_size do
|
||||
for _ <- 1..@omok_board_size, do: 0
|
||||
end
|
||||
end
|
||||
|
||||
defp get_board_piece(board, x, y) do
|
||||
row = Enum.at(board, y)
|
||||
Enum.at(row, x)
|
||||
end
|
||||
|
||||
defp set_board_piece(board, x, y, piece) do
|
||||
row = Enum.at(board, y)
|
||||
new_row = List.replace_at(row, x, piece)
|
||||
List.replace_at(board, y, new_row)
|
||||
end
|
||||
|
||||
defp generate_match_cards(piece_type) do
|
||||
matches_needed = get_matches_to_win(piece_type)
|
||||
|
||||
cards =
|
||||
for i <- 0..(matches_needed - 1) do
|
||||
[i, i]
|
||||
end
|
||||
|> List.flatten()
|
||||
|
||||
# Shuffle cards
|
||||
Enum.shuffle(cards)
|
||||
end
|
||||
|
||||
# Omok win checking - check all directions from the last move
|
||||
defp check_omok_win(board, x, y, piece_type) do
|
||||
directions = [
|
||||
{1, 0}, # Horizontal
|
||||
{0, 1}, # Vertical
|
||||
{1, 1}, # Diagonal \
|
||||
{1, -1} # Diagonal /
|
||||
]
|
||||
|
||||
Enum.any?(directions, fn {dx, dy} ->
|
||||
count = count_in_direction(board, x, y, dx, dy, piece_type) +
|
||||
count_in_direction(board, x, y, -dx, -dy, piece_type) - 1
|
||||
count >= 5
|
||||
end)
|
||||
end
|
||||
|
||||
defp count_in_direction(board, x, y, dx, dy, piece_type, count \\ 0) do
|
||||
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
||||
count
|
||||
else
|
||||
piece = get_board_piece(board, x, y)
|
||||
|
||||
if piece == piece_type do
|
||||
count_in_direction(board, x + dx, y + dy, dx, dy, piece_type, count + 1)
|
||||
else
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
309
lib/odinsea/game/monster_status.ex
Normal file
309
lib/odinsea/game/monster_status.ex
Normal file
@@ -0,0 +1,309 @@
|
||||
defmodule Odinsea.Game.MonsterStatus do
|
||||
@moduledoc """
|
||||
Monster status effects (buffs/debuffs) for MapleStory.
|
||||
|
||||
Ported from Java: client/status/MobStat.java
|
||||
|
||||
MonsterStatus represents status effects that can be applied to monsters:
|
||||
- PAD (Physical Attack Damage)
|
||||
- PDD (Physical Defense)
|
||||
- MAD (Magic Attack Damage)
|
||||
- MDD (Magic Defense)
|
||||
- ACC (Accuracy)
|
||||
- EVA (Evasion)
|
||||
- Speed
|
||||
- Stun
|
||||
- Freeze
|
||||
- Poison
|
||||
- Seal
|
||||
- And more...
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
|
||||
@type t ::
|
||||
:pad
|
||||
| :pdd
|
||||
| :mad
|
||||
| :mdd
|
||||
| :acc
|
||||
| :eva
|
||||
| :speed
|
||||
| :stun
|
||||
| :freeze
|
||||
| :poison
|
||||
| :seal
|
||||
| :darkness
|
||||
| :power_up
|
||||
| :magic_up
|
||||
| :p_guard_up
|
||||
| :m_guard_up
|
||||
| :doom
|
||||
| :web
|
||||
| :p_immune
|
||||
| :m_immune
|
||||
| :showdown
|
||||
| :hard_skin
|
||||
| :ambush
|
||||
| :damaged_elem_attr
|
||||
| :venom
|
||||
| :blind
|
||||
| :seal_skill
|
||||
| :burned
|
||||
| :dazzle
|
||||
| :p_counter
|
||||
| :m_counter
|
||||
| :disable
|
||||
| :rise_by_toss
|
||||
| :body_pressure
|
||||
| :weakness
|
||||
| :time_bomb
|
||||
| :magic_crash
|
||||
| :exchange_attack
|
||||
| :heal_by_damage
|
||||
| :invincible
|
||||
|
||||
@doc """
|
||||
All monster status effects with their bit values and positions.
|
||||
Format: {status_name, bit_value, position}
|
||||
Position 1 = first int, Position 2 = second int
|
||||
"""
|
||||
def all_statuses do
|
||||
[
|
||||
# Position 1 (first int)
|
||||
{:pad, 0x1, 1},
|
||||
{:pdd, 0x2, 1},
|
||||
{:mad, 0x4, 1},
|
||||
{:mdd, 0x8, 1},
|
||||
{:acc, 0x10, 1},
|
||||
{:eva, 0x20, 1},
|
||||
{:speed, 0x40, 1},
|
||||
{:stun, 0x80, 1},
|
||||
{:freeze, 0x100, 1},
|
||||
{:poison, 0x200, 1},
|
||||
{:seal, 0x400, 1},
|
||||
{:darkness, 0x800, 1},
|
||||
{:power_up, 0x1000, 1},
|
||||
{:magic_up, 0x2000, 1},
|
||||
{:p_guard_up, 0x4000, 1},
|
||||
{:m_guard_up, 0x8000, 1},
|
||||
{:doom, 0x10000, 1},
|
||||
{:web, 0x20000, 1},
|
||||
{:p_immune, 0x40000, 1},
|
||||
{:m_immune, 0x80000, 1},
|
||||
{:showdown, 0x100000, 1},
|
||||
{:hard_skin, 0x200000, 1},
|
||||
{:ambush, 0x400000, 1},
|
||||
{:damaged_elem_attr, 0x800000, 1},
|
||||
{:venom, 0x1000000, 1},
|
||||
{:blind, 0x2000000, 1},
|
||||
{:seal_skill, 0x4000000, 1},
|
||||
{:burned, 0x8000000, 1},
|
||||
{:dazzle, 0x10000000, 1},
|
||||
{:p_counter, 0x20000000, 1},
|
||||
{:m_counter, 0x40000000, 1},
|
||||
{:disable, 0x80000000, 1},
|
||||
|
||||
# Position 2 (second int)
|
||||
{:rise_by_toss, 0x1, 2},
|
||||
{:body_pressure, 0x2, 2},
|
||||
{:weakness, 0x4, 2},
|
||||
{:time_bomb, 0x8, 2},
|
||||
{:magic_crash, 0x10, 2},
|
||||
{:exchange_attack, 0x20, 2},
|
||||
{:heal_by_damage, 0x40, 2},
|
||||
{:invincible, 0x80, 2}
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the bit value for a status effect.
|
||||
"""
|
||||
@spec get_bit(t()) :: integer()
|
||||
def get_bit(status) do
|
||||
case List.keyfind(all_statuses(), status, 0) do
|
||||
{_, bit, _} -> bit
|
||||
nil -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the position (1 or 2) for a status effect.
|
||||
"""
|
||||
@spec get_position(t()) :: integer()
|
||||
def get_position(status) do
|
||||
case List.keyfind(all_statuses(), status, 0) do
|
||||
{_, _, pos} -> pos
|
||||
nil -> 1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a status is in position 1.
|
||||
"""
|
||||
@spec position_1?(t()) :: boolean()
|
||||
def position_1?(status) do
|
||||
get_position(status) == 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a status is in position 2.
|
||||
"""
|
||||
@spec position_2?(t()) :: boolean()
|
||||
def position_2?(status) do
|
||||
get_position(status) == 2
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all statuses in position 1.
|
||||
"""
|
||||
@spec position_1_statuses() :: [t()]
|
||||
def position_1_statuses do
|
||||
all_statuses()
|
||||
|> Enum.filter(fn {_, _, pos} -> pos == 1 end)
|
||||
|> Enum.map(fn {status, _, _} -> status end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all statuses in position 2.
|
||||
"""
|
||||
@spec position_2_statuses() :: [t()]
|
||||
def position_2_statuses do
|
||||
all_statuses()
|
||||
|> Enum.filter(fn {_, _, pos} -> pos == 2 end)
|
||||
|> Enum.map(fn {status, _, _} -> status end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a map of status effects to bitmasks.
|
||||
Returns {mask1, mask2} where each is an integer bitmask.
|
||||
"""
|
||||
@spec encode_statuses(%{t() => integer()}) :: {integer(), integer()}
|
||||
def encode_statuses(statuses) when is_map(statuses) do
|
||||
mask1 =
|
||||
statuses
|
||||
|> Enum.filter(fn {status, _} -> position_1?(status) end)
|
||||
|> Enum.reduce(0, fn {status, _}, acc -> acc ||| get_bit(status) end)
|
||||
|
||||
mask2 =
|
||||
statuses
|
||||
|> Enum.filter(fn {status, _} -> position_2?(status) end)
|
||||
|> Enum.reduce(0, fn {status, _}, acc -> acc ||| get_bit(status) end)
|
||||
|
||||
{mask1, mask2}
|
||||
end
|
||||
|
||||
def encode_statuses(_), do: {0, 0}
|
||||
|
||||
@doc """
|
||||
Decodes bitmasks to a list of status effects.
|
||||
"""
|
||||
@spec decode_statuses(integer(), integer()) :: [t()]
|
||||
def decode_statuses(mask1, mask2) do
|
||||
pos1 =
|
||||
position_1_statuses()
|
||||
|> Enum.filter(fn status -> (mask1 &&& get_bit(status)) != 0 end)
|
||||
|
||||
pos2 =
|
||||
position_2_statuses()
|
||||
|> Enum.filter(fn status -> (mask2 &&& get_bit(status)) != 0 end)
|
||||
|
||||
pos1 ++ pos2
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the linked disease for a monster status.
|
||||
Used when converting monster debuffs to player diseases.
|
||||
"""
|
||||
@spec get_linked_disease(t()) :: atom() | nil
|
||||
def get_linked_disease(status) do
|
||||
case status do
|
||||
:stun -> :stun
|
||||
:web -> :stun
|
||||
:poison -> :poison
|
||||
:venom -> :poison
|
||||
:seal -> :seal
|
||||
:magic_crash -> :seal
|
||||
:freeze -> :freeze
|
||||
:blind -> :darkness
|
||||
:speed -> :slow
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the monster status from a Pokemon-style skill ID.
|
||||
Used for familiar/capture card mechanics.
|
||||
"""
|
||||
@spec from_pokemon_skill(integer()) :: t() | nil
|
||||
def from_pokemon_skill(skill_id) do
|
||||
case skill_id do
|
||||
120 -> :seal
|
||||
121 -> :blind
|
||||
123 -> :stun
|
||||
125 -> :poison
|
||||
126 -> :speed
|
||||
137 -> :freeze
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a stun effect (prevents movement).
|
||||
"""
|
||||
@spec is_stun?(t()) :: boolean()
|
||||
def is_stun?(status) do
|
||||
status in [:stun, :freeze]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a damage over time effect.
|
||||
"""
|
||||
@spec is_dot?(t()) :: boolean()
|
||||
def is_dot?(status) do
|
||||
status in [:poison, :venom, :burned]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a debuff (negative effect).
|
||||
"""
|
||||
@spec is_debuff?(t()) :: boolean()
|
||||
def is_debuff?(status) do
|
||||
status in [
|
||||
:stun, :freeze, :poison, :seal, :darkness, :doom, :web,
|
||||
:blind, :seal_skill, :burned, :dazzle, :speed, :weakness,
|
||||
:time_bomb, :magic_crash, :disable, :rise_by_toss
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the status is a buff (positive effect).
|
||||
"""
|
||||
@spec is_buff?(t()) :: boolean()
|
||||
def is_buff?(status) do
|
||||
status in [
|
||||
:pad, :pdd, :mad, :mdd, :acc, :eva, :power_up, :magic_up,
|
||||
:p_guard_up, :m_guard_up, :hard_skin, :invincible, :heal_by_damage
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default duration for a status effect in milliseconds.
|
||||
"""
|
||||
@spec default_duration(t()) :: integer()
|
||||
def default_duration(status) do
|
||||
case status do
|
||||
:stun -> 3000
|
||||
:freeze -> 5000
|
||||
:poison -> 8000
|
||||
:seal -> 5000
|
||||
:darkness -> 8000
|
||||
:doom -> 10000
|
||||
:web -> 8000
|
||||
:blind -> 8000
|
||||
:speed -> 8000
|
||||
:weakness -> 8000
|
||||
_ -> 5000
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Odinsea.Game.Movement do
|
||||
@moduledoc """
|
||||
Movement parsing and validation for players, mobs, pets, summons, and dragons.
|
||||
Ported from Java MovementParse.java.
|
||||
Ported from Java MovementParse.java and all movement type classes.
|
||||
|
||||
Movement types (kind):
|
||||
- 1: Player
|
||||
@@ -9,138 +9,689 @@ defmodule Odinsea.Game.Movement do
|
||||
- 3: Pet
|
||||
- 4: Summon
|
||||
- 5: Dragon
|
||||
- 6: Familiar
|
||||
|
||||
This is a SIMPLIFIED implementation for now. The full Java version has complex
|
||||
parsing for different movement command types. We'll expand this as needed.
|
||||
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.Character.Position
|
||||
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}.
|
||||
|
||||
For now, this returns a simplified structure. The full implementation
|
||||
would parse all movement fragment types.
|
||||
## Examples
|
||||
|
||||
iex> Movement.parse_movement(packet, 1) # Player movement
|
||||
{:ok, [%Absolute{command: 0, x: 100, y: 200, ...}, ...]}
|
||||
|
||||
"""
|
||||
def parse_movement(packet, _kind) do
|
||||
def parse_movement(packet, kind) do
|
||||
num_commands = In.decode_byte(packet)
|
||||
|
||||
# For now, just skip through the movement data and extract final position
|
||||
# TODO: Implement full movement parsing with all command types
|
||||
case extract_final_position(packet, num_commands) do
|
||||
{:ok, position} ->
|
||||
{:ok, %{num_commands: num_commands, final_position: position}}
|
||||
case parse_commands(packet, kind, num_commands, []) do
|
||||
{:ok, movements} when length(movements) == num_commands ->
|
||||
{:ok, Enum.reverse(movements)}
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_movement}
|
||||
{: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, character_id) do
|
||||
# TODO: Implement position update logic
|
||||
# For now, just return ok
|
||||
Logger.debug("Update position for character #{character_id}")
|
||||
:ok
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
# Extract the final position from movement data
|
||||
# This is a TEMPORARY simplification - we just read through the movement
|
||||
# commands and try to extract the last absolute position
|
||||
defp extract_final_position(packet, num_commands) do
|
||||
try do
|
||||
final_pos = parse_commands(packet, num_commands, nil)
|
||||
{:ok, final_pos || %{x: 0, y: 0, stance: 0, foothold: 0}}
|
||||
rescue
|
||||
_ ->
|
||||
:error
|
||||
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
|
||||
|
||||
defp parse_commands(_packet, 0, last_position) do
|
||||
last_position
|
||||
# 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
|
||||
|
||||
defp parse_commands(packet, remaining, last_position) do
|
||||
command = In.decode_byte(packet)
|
||||
# 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)
|
||||
|
||||
new_position =
|
||||
case command do
|
||||
# Absolute movement commands - extract position
|
||||
cmd when cmd in [0, 37, 38, 39, 40, 41, 42] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Relative movement - skip for now
|
||||
cmd when cmd in [1, 2, 33, 34, 36] ->
|
||||
_xmod = In.decode_short(packet)
|
||||
_ymod = In.decode_short(packet)
|
||||
_stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Teleport movement
|
||||
cmd when cmd in [3, 4, 8, 100, 101] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Chair movement
|
||||
cmd when cmd in [9, 12] ->
|
||||
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)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Aran combat step
|
||||
cmd when cmd in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] ->
|
||||
_stance = In.decode_byte(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Jump down
|
||||
cmd when cmd in [13, 14] ->
|
||||
# Simplified - just skip the data
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_fh = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Unknown/unhandled - log and skip
|
||||
_ ->
|
||||
Logger.warning("Unhandled movement command: #{command}")
|
||||
last_position
|
||||
end
|
||||
|
||||
parse_commands(packet, remaining - 1, new_position || last_position)
|
||||
{: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
|
||||
|
||||
39
lib/odinsea/game/movement/absolute.ex
Normal file
39
lib/odinsea/game/movement/absolute.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule Odinsea.Game.Movement.Absolute do
|
||||
@moduledoc """
|
||||
Absolute life movement - normal walking, flying, etc.
|
||||
Ported from Java AbsoluteLifeMovement.java
|
||||
|
||||
This is the most common movement type for:
|
||||
- Normal walking (command 0)
|
||||
- Flying (commands 37-42)
|
||||
- Rush skills (when not instant)
|
||||
|
||||
Contains position, velocity, and offset information.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (0, 37-42)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity (pixels per second)
|
||||
vy: integer(), # Y velocity (pixels per second)
|
||||
unk: integer(), # Unknown short value
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:unk,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
24
lib/odinsea/game/movement/aran.ex
Normal file
24
lib/odinsea/game/movement/aran.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule Odinsea.Game.Movement.Aran do
|
||||
@moduledoc """
|
||||
Aran movement - Aran class combat step movements.
|
||||
Ported from Java AranMovement.java
|
||||
|
||||
Used for:
|
||||
- Aran combat steps (commands 21-31)
|
||||
- Special Aran skills (command 35)
|
||||
|
||||
Note: Position is not used for this movement type.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (21-31, 35)
|
||||
stance: integer(), # New stance/move action
|
||||
unk: integer() # Unknown short value
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:stance,
|
||||
:unk
|
||||
]
|
||||
end
|
||||
31
lib/odinsea/game/movement/bounce.ex
Normal file
31
lib/odinsea/game/movement/bounce.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Odinsea.Game.Movement.Bounce do
|
||||
@moduledoc """
|
||||
Bounce movement - bouncing off surfaces.
|
||||
Ported from Java BounceMovement.java
|
||||
|
||||
Used for:
|
||||
- Bouncing (command -1)
|
||||
- Wall bouncing (commands 18, 19)
|
||||
- Platform bouncing (commands 5-7)
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (-1, 5-7, 18, 19)
|
||||
x: integer(), # Bounce X position
|
||||
y: integer(), # Bounce Y position
|
||||
unk: integer(), # Unknown short value
|
||||
foothold: integer(), # Foothold after bounce
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:unk,
|
||||
:foothold,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
29
lib/odinsea/game/movement/chair.ex
Normal file
29
lib/odinsea/game/movement/chair.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule Odinsea.Game.Movement.Chair do
|
||||
@moduledoc """
|
||||
Chair movement - sitting on chairs/mounts.
|
||||
Ported from Java ChairMovement.java
|
||||
|
||||
Used for:
|
||||
- Sitting on chairs (commands 9, 12)
|
||||
- Mount riding (command 13 in GMS)
|
||||
- Special seating
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (9, 10, 11, 12, 13)
|
||||
x: integer(), # Chair X position
|
||||
y: integer(), # Chair Y position
|
||||
unk: integer(), # Unknown short value
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:unk,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
22
lib/odinsea/game/movement/change_equip.ex
Normal file
22
lib/odinsea/game/movement/change_equip.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Odinsea.Game.Movement.ChangeEquip do
|
||||
@moduledoc """
|
||||
Change equip special awesome - equipment change during movement.
|
||||
Ported from Java ChangeEquipSpecialAwesome.java
|
||||
|
||||
Used for:
|
||||
- Changing equipment mid-movement (commands 10, 11)
|
||||
- Quick gear switching
|
||||
|
||||
Note: Position is always 0,0 for this fragment type.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (10, 11)
|
||||
wui: integer() # Weapon upgrade index or similar
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:wui
|
||||
]
|
||||
end
|
||||
40
lib/odinsea/game/movement/jump_down.ex
Normal file
40
lib/odinsea/game/movement/jump_down.ex
Normal file
@@ -0,0 +1,40 @@
|
||||
defmodule Odinsea.Game.Movement.JumpDown do
|
||||
@moduledoc """
|
||||
Jump down movement - falling through platforms.
|
||||
Ported from Java JumpDownMovement.java
|
||||
|
||||
Used for:
|
||||
- Jumping down through platforms (commands 13, 14)
|
||||
- Controlled falling
|
||||
|
||||
Contains foothold information for landing detection.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (13, 14)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
unk: integer(), # Unknown short value
|
||||
foothold: integer(), # Target foothold ID
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:unk,
|
||||
:foothold,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
443
lib/odinsea/game/movement/path.ex
Normal file
443
lib/odinsea/game/movement/path.ex
Normal file
@@ -0,0 +1,443 @@
|
||||
defmodule Odinsea.Game.Movement.Path do
|
||||
@moduledoc """
|
||||
MovePath for mob movement (newer movement system).
|
||||
Ported from Java MovePath.java
|
||||
|
||||
This is an alternative movement system used by mobs in newer
|
||||
versions of MapleStory. It uses a more compact encoding.
|
||||
|
||||
Structure:
|
||||
- Initial position (x, y, vx, vy)
|
||||
- List of movement elements
|
||||
- Optional passive data (keypad states, movement rect)
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
alias Odinsea.Net.Packet.In
|
||||
|
||||
defstruct [
|
||||
:x, # Initial X position
|
||||
:y, # Initial Y position
|
||||
:vx, # Initial X velocity
|
||||
:vy, # Initial Y velocity
|
||||
elements: [], # List of MoveElem
|
||||
key_pad_states: [], # Keypad states (passive mode)
|
||||
move_rect: nil # Movement rectangle (passive mode)
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
x: integer() | nil,
|
||||
y: integer() | nil,
|
||||
vx: integer() | nil,
|
||||
vy: integer() | nil,
|
||||
elements: list(MoveElem.t()),
|
||||
key_pad_states: list(integer()),
|
||||
move_rect: map() | nil
|
||||
}
|
||||
|
||||
defmodule MoveElem do
|
||||
@moduledoc """
|
||||
Individual movement element within a MovePath.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
attribute: integer(), # Movement type/attribute
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
fh: integer(), # Foothold
|
||||
fall_start: integer(), # Fall start position
|
||||
offset_x: integer(), # X offset
|
||||
offset_y: integer(), # Y offset
|
||||
sn: integer(), # Skill/stat number
|
||||
move_action: integer(), # Move action/stance
|
||||
elapse: integer() # Elapsed time
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:attribute,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:fh,
|
||||
:fall_start,
|
||||
:offset_x,
|
||||
:offset_y,
|
||||
:sn,
|
||||
:move_action,
|
||||
:elapse
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decodes a MovePath from a packet.
|
||||
|
||||
## Parameters
|
||||
- packet: The incoming packet
|
||||
- passive: Whether to decode passive data (keypad, rect)
|
||||
|
||||
## Returns
|
||||
%MovePath{} struct with decoded data
|
||||
"""
|
||||
def decode(packet, passive \\ false) do
|
||||
old_x = In.decode_short(packet)
|
||||
old_y = In.decode_short(packet)
|
||||
old_vx = In.decode_short(packet)
|
||||
old_vy = In.decode_short(packet)
|
||||
|
||||
count = In.decode_byte(packet)
|
||||
|
||||
{elements, final_x, final_y, final_vx, final_vy, _fh_last} =
|
||||
decode_elements(packet, count, old_x, old_y, old_vx, old_vy, [])
|
||||
|
||||
path = %__MODULE__{
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
elements: Enum.reverse(elements)
|
||||
}
|
||||
|
||||
if passive do
|
||||
{key_pad_states, move_rect} = decode_passive_data(packet)
|
||||
%{path |
|
||||
x: final_x,
|
||||
y: final_y,
|
||||
vx: final_vx,
|
||||
vy: final_vy,
|
||||
key_pad_states: key_pad_states,
|
||||
move_rect: move_rect
|
||||
}
|
||||
else
|
||||
%{path |
|
||||
x: final_x,
|
||||
y: final_y,
|
||||
vx: final_vx,
|
||||
vy: final_vy
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a MovePath to binary for packet output.
|
||||
"""
|
||||
def encode(%__MODULE__{} = path, _passive \\ false) do
|
||||
elements_data = Enum.map_join(path.elements, &encode_element/1)
|
||||
|
||||
<<path.x::16-little, path.y::16-little,
|
||||
path.vx::16-little, path.vy::16-little,
|
||||
length(path.elements)::8,
|
||||
elements_data::binary>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final position from the MovePath.
|
||||
"""
|
||||
def get_final_position(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> %{x: path.x, y: path.y}
|
||||
elem -> %{x: elem.x, y: elem.y}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final move action/stance from the MovePath.
|
||||
"""
|
||||
def get_final_action(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> 0
|
||||
elem -> elem.move_action
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the final foothold from the MovePath.
|
||||
"""
|
||||
def get_final_foothold(%__MODULE__{} = path) do
|
||||
case List.last(path.elements) do
|
||||
nil -> 0
|
||||
elem -> elem.fh
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp decode_elements(_packet, 0, old_x, old_y, old_vx, old_vy, acc),
|
||||
do: {acc, old_x, old_y, old_vx, old_vy, 0}
|
||||
|
||||
defp decode_elements(packet, count, old_x, old_y, old_vx, old_vy, acc) do
|
||||
attr = In.decode_byte(packet)
|
||||
|
||||
{elem, new_x, new_y, new_vx, new_vy, _fh_last} =
|
||||
case attr do
|
||||
# Absolute with foothold
|
||||
a when a in [0, 6, 13, 15, 37, 38] ->
|
||||
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)
|
||||
|
||||
fall_start = if attr == 13, do: In.decode_short(packet), else: 0
|
||||
offset_x = In.decode_short(packet)
|
||||
offset_y = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: fh,
|
||||
fall_start: fall_start,
|
||||
offset_x: offset_x,
|
||||
offset_y: offset_y
|
||||
}
|
||||
{elem, x, y, vx, vy, fh}
|
||||
|
||||
# Velocity only
|
||||
a when a in [1, 2, 14, 17, 19, 32, 33, 34, 35] ->
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, vx, vy, 0}
|
||||
|
||||
# Position with foothold
|
||||
a when a in [3, 4, 5, 7, 8, 9, 11] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
fh = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fh: fh
|
||||
}
|
||||
{elem, x, y, 0, 0, fh}
|
||||
|
||||
# Stat change
|
||||
10 ->
|
||||
sn = In.decode_byte(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
sn: sn,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fh: 0,
|
||||
elapse: 0,
|
||||
move_action: 0
|
||||
}
|
||||
{elem, old_x, old_y, 0, 0, 0}
|
||||
|
||||
# Start fall down
|
||||
12 ->
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
fall_start = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0,
|
||||
fall_start: fall_start
|
||||
}
|
||||
{elem, old_x, old_y, vx, vy, 0}
|
||||
|
||||
# Flying block
|
||||
18 ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
vx = In.decode_short(packet)
|
||||
vy = In.decode_short(packet)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, x, y, vx, vy, 0}
|
||||
|
||||
# No change (21-31)
|
||||
a when a in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] ->
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, old_vx, old_vy, 0}
|
||||
|
||||
# Special case 36
|
||||
36 ->
|
||||
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)
|
||||
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
fh: fh
|
||||
}
|
||||
{elem, x, y, vx, vy, fh}
|
||||
|
||||
# Unknown attribute - skip gracefully
|
||||
_unknown ->
|
||||
elem = %MoveElem{
|
||||
attribute: attr,
|
||||
x: old_x,
|
||||
y: old_y,
|
||||
vx: old_vx,
|
||||
vy: old_vy,
|
||||
fh: 0
|
||||
}
|
||||
{elem, old_x, old_y, old_vx, old_vy, 0}
|
||||
end
|
||||
|
||||
# Read move action and elapse (except for stat change)
|
||||
{elem, new_x, new_y, new_vx, new_vy} =
|
||||
if attr != 10 do
|
||||
move_action = In.decode_byte(packet)
|
||||
elapse = In.decode_short(packet)
|
||||
|
||||
{%{elem |
|
||||
move_action: move_action,
|
||||
elapse: elapse
|
||||
}, elem.x, elem.y, elem.vx, elem.vy}
|
||||
else
|
||||
{elem, new_x, new_y, new_vx, new_vy}
|
||||
end
|
||||
|
||||
decode_elements(
|
||||
packet,
|
||||
count - 1,
|
||||
new_x,
|
||||
new_y,
|
||||
new_vx,
|
||||
new_vy,
|
||||
[elem | acc]
|
||||
)
|
||||
end
|
||||
|
||||
defp decode_passive_data(packet) do
|
||||
keys = In.decode_byte(packet)
|
||||
|
||||
key_pad_states =
|
||||
if keys > 0 do
|
||||
decode_keypad_states(packet, keys, 0, [])
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
move_rect = %{
|
||||
left: In.decode_short(packet),
|
||||
top: In.decode_short(packet),
|
||||
right: In.decode_short(packet),
|
||||
bottom: In.decode_short(packet)
|
||||
}
|
||||
|
||||
{Enum.reverse(key_pad_states), move_rect}
|
||||
end
|
||||
|
||||
defp decode_keypad_states(_packet, 0, _value, acc), do: acc
|
||||
|
||||
defp decode_keypad_states(packet, remaining, value, acc) do
|
||||
{new_value, decoded} =
|
||||
if rem(length(acc), 2) != 0 do
|
||||
{bsr(value, 4), band(value, 0x0F)}
|
||||
else
|
||||
v = In.decode_byte(packet)
|
||||
{v, band(v, 0x0F)}
|
||||
end
|
||||
|
||||
decode_keypad_states(packet, remaining - 1, new_value, [decoded | acc])
|
||||
end
|
||||
|
||||
defp encode_element(%MoveElem{} = elem) do
|
||||
attr = elem.attribute
|
||||
|
||||
base = <<attr::8>>
|
||||
|
||||
data =
|
||||
case attr do
|
||||
a when a in [0, 6, 13, 15, 37, 38] ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fh::16-little>> <>
|
||||
if attr == 13 do
|
||||
<<elem.fall_start::16-little>>
|
||||
else
|
||||
<<>>
|
||||
end <>
|
||||
<<elem.offset_x::16-little, elem.offset_y::16-little>>
|
||||
|
||||
a when a in [1, 2, 14, 17, 19, 32, 33, 34, 35] ->
|
||||
<<elem.vx::16-little, elem.vy::16-little>>
|
||||
|
||||
a when a in [3, 4, 5, 7, 8, 9, 11] ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.fh::16-little>>
|
||||
|
||||
10 ->
|
||||
<<elem.sn::8>>
|
||||
|
||||
12 ->
|
||||
<<elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fall_start::16-little>>
|
||||
|
||||
18 ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little>>
|
||||
|
||||
a when a in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] ->
|
||||
<<>>
|
||||
|
||||
36 ->
|
||||
<<elem.x::16-little, elem.y::16-little,
|
||||
elem.vx::16-little, elem.vy::16-little,
|
||||
elem.fh::16-little>>
|
||||
|
||||
_ ->
|
||||
<<>>
|
||||
end
|
||||
|
||||
footer =
|
||||
if attr != 10 do
|
||||
<<elem.move_action::8, elem.elapse::16-little>>
|
||||
else
|
||||
<<>>
|
||||
end
|
||||
|
||||
base <> data <> footer
|
||||
end
|
||||
end
|
||||
29
lib/odinsea/game/movement/relative.ex
Normal file
29
lib/odinsea/game/movement/relative.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule Odinsea.Game.Movement.Relative do
|
||||
@moduledoc """
|
||||
Relative life movement - small position adjustments.
|
||||
Ported from Java RelativeLifeMovement.java
|
||||
|
||||
Used for:
|
||||
- Small adjustments (commands 1, 2)
|
||||
- Float movements (commands 33, 34, 36)
|
||||
- Fine-tuning position
|
||||
|
||||
Contains relative offset from current position.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (1, 2, 33, 34, 36)
|
||||
x: integer(), # X offset (delta from current)
|
||||
y: integer(), # Y offset (delta from current)
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
32
lib/odinsea/game/movement/teleport.ex
Normal file
32
lib/odinsea/game/movement/teleport.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Odinsea.Game.Movement.Teleport do
|
||||
@moduledoc """
|
||||
Teleport movement - instant position change.
|
||||
Ported from Java TeleportMovement.java
|
||||
|
||||
Used for:
|
||||
- Rush skills (command 3)
|
||||
- Teleport (command 4)
|
||||
- Assassinate (command 8)
|
||||
- Special skills (commands 100, 101)
|
||||
|
||||
Note: Duration is always 0 for teleports.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (3, 4, 8, 100, 101)
|
||||
x: integer(), # Target X position
|
||||
y: integer(), # Target Y position
|
||||
vx: integer(), # X velocity (visual effect)
|
||||
vy: integer(), # Y velocity (visual effect)
|
||||
stance: integer() # New stance/move action
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:stance
|
||||
]
|
||||
end
|
||||
36
lib/odinsea/game/movement/unknown.ex
Normal file
36
lib/odinsea/game/movement/unknown.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Odinsea.Game.Movement.Unknown do
|
||||
@moduledoc """
|
||||
Unknown movement type - placeholder for unhandled commands.
|
||||
Ported from Java UnknownMovement.java
|
||||
|
||||
Used for:
|
||||
- Command 32 (unknown structure)
|
||||
- Any future/unrecognized movement types
|
||||
|
||||
Parses generic structure that may match unknown commands.
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
command: integer(), # Movement command type (32, or unknown)
|
||||
unk: integer(), # Unknown short value
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
vx: integer(), # X velocity
|
||||
vy: integer(), # Y velocity
|
||||
foothold: integer(), # Foothold
|
||||
stance: integer(), # New stance/move action
|
||||
duration: integer() # Movement duration in ms
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:command,
|
||||
:unk,
|
||||
:x,
|
||||
:y,
|
||||
:vx,
|
||||
:vy,
|
||||
:foothold,
|
||||
:stance,
|
||||
:duration
|
||||
]
|
||||
end
|
||||
332
lib/odinsea/game/pet.ex
Normal file
332
lib/odinsea/game/pet.ex
Normal file
@@ -0,0 +1,332 @@
|
||||
defmodule Odinsea.Game.Pet do
|
||||
@moduledoc """
|
||||
Represents a pet in the game.
|
||||
Ported from src/client/inventory/MaplePet.java
|
||||
|
||||
Pets are companions that follow players, can pick up items, and provide buffs.
|
||||
Each pet has:
|
||||
- Level and closeness (affection) that grows through interaction
|
||||
- Fullness (hunger) that must be maintained by feeding
|
||||
- Flags for special abilities (item pickup, auto-buff, etc.)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.PetData
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
# Identity
|
||||
unique_id: integer(),
|
||||
pet_item_id: integer(),
|
||||
name: String.t(),
|
||||
|
||||
# Stats
|
||||
level: byte(),
|
||||
closeness: integer(),
|
||||
fullness: byte(),
|
||||
|
||||
# Position (when summoned)
|
||||
position: %{x: integer(), y: integer(), fh: integer()},
|
||||
stance: integer(),
|
||||
|
||||
# State
|
||||
summoned: byte(),
|
||||
inventory_position: integer(),
|
||||
seconds_left: integer(),
|
||||
|
||||
# Abilities (bitmask flags)
|
||||
flags: integer(),
|
||||
|
||||
# Change tracking
|
||||
changed: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:unique_id,
|
||||
:pet_item_id,
|
||||
:name,
|
||||
:level,
|
||||
:closeness,
|
||||
:fullness,
|
||||
:position,
|
||||
:stance,
|
||||
:summoned,
|
||||
:inventory_position,
|
||||
:seconds_left,
|
||||
:flags,
|
||||
:changed
|
||||
]
|
||||
|
||||
@max_closeness 30_000
|
||||
@max_fullness 100
|
||||
@default_fullness 100
|
||||
@default_level 1
|
||||
|
||||
@doc """
|
||||
Creates a new pet with default values.
|
||||
"""
|
||||
def new(pet_item_id, unique_id, name \\ nil) do
|
||||
name = name || PetData.get_default_pet_name(pet_item_id)
|
||||
|
||||
%__MODULE__{
|
||||
unique_id: unique_id,
|
||||
pet_item_id: pet_item_id,
|
||||
name: name,
|
||||
level: @default_level,
|
||||
closeness: 0,
|
||||
fullness: @default_fullness,
|
||||
position: %{x: 0, y: 0, fh: 0},
|
||||
stance: 0,
|
||||
summoned: 0,
|
||||
inventory_position: 0,
|
||||
seconds_left: 0,
|
||||
flags: 0,
|
||||
changed: true
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a pet from database values.
|
||||
"""
|
||||
def from_db(pet_item_id, unique_id, attrs) do
|
||||
%__MODULE__{
|
||||
unique_id: unique_id,
|
||||
pet_item_id: pet_item_id,
|
||||
name: attrs[:name] || "",
|
||||
level: attrs[:level] || @default_level,
|
||||
closeness: attrs[:closeness] || 0,
|
||||
fullness: attrs[:fullness] || @default_fullness,
|
||||
position: %{x: 0, y: 0, fh: 0},
|
||||
stance: 0,
|
||||
summoned: 0,
|
||||
inventory_position: attrs[:inventory_position] || 0,
|
||||
seconds_left: attrs[:seconds_left] || 0,
|
||||
flags: attrs[:flags] || 0,
|
||||
changed: false
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's name.
|
||||
"""
|
||||
def set_name(%__MODULE__{} = pet, name) do
|
||||
%{pet | name: name, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's summoned state.
|
||||
- 0 = not summoned
|
||||
- 1, 2, 3 = summoned in corresponding slot
|
||||
"""
|
||||
def set_summoned(%__MODULE__{} = pet, summoned) when summoned in [0, 1, 2, 3] do
|
||||
%{pet | summoned: summoned}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet is currently summoned.
|
||||
"""
|
||||
def summoned?(%__MODULE__{} = pet) do
|
||||
pet.summoned > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the inventory position of the pet item.
|
||||
"""
|
||||
def set_inventory_position(%__MODULE__{} = pet, position) do
|
||||
%{pet | inventory_position: position}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds closeness (affection) to the pet.
|
||||
Returns {:level_up, pet} if pet leveled up, {:ok, pet} otherwise.
|
||||
"""
|
||||
def add_closeness(%__MODULE__{} = pet, amount) do
|
||||
new_closeness = min(@max_closeness, pet.closeness + amount)
|
||||
next_level_req = PetData.closeness_for_level(pet.level + 1)
|
||||
|
||||
pet = %{pet | closeness: new_closeness, changed: true}
|
||||
|
||||
if new_closeness >= next_level_req and pet.level < 30 do
|
||||
{:level_up, level_up(pet)}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes closeness from the pet (e.g., when fullness is 0).
|
||||
May cause level down.
|
||||
Returns {:level_down, pet} if pet leveled down, {:ok, pet} otherwise.
|
||||
"""
|
||||
def remove_closeness(%__MODULE__{} = pet, amount) do
|
||||
new_closeness = max(0, pet.closeness - amount)
|
||||
current_level_req = PetData.closeness_for_level(pet.level)
|
||||
|
||||
pet = %{pet | closeness: new_closeness, changed: true}
|
||||
|
||||
if new_closeness < current_level_req and pet.level > 1 do
|
||||
{:level_down, %{pet | level: pet.level - 1}}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Levels up the pet.
|
||||
"""
|
||||
def level_up(%__MODULE__{} = pet) do
|
||||
%{pet | level: min(30, pet.level + 1), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds fullness to the pet (when fed).
|
||||
Max fullness is 100.
|
||||
"""
|
||||
def add_fullness(%__MODULE__{} = pet, amount) do
|
||||
new_fullness = min(@max_fullness, pet.fullness + amount)
|
||||
%{pet | fullness: new_fullness, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decreases fullness (called periodically by hunger timer).
|
||||
May decrease closeness if fullness reaches 0.
|
||||
"""
|
||||
def decrease_fullness(%__MODULE__{} = pet, amount) do
|
||||
new_fullness = max(0, pet.fullness - amount)
|
||||
pet = %{pet | fullness: new_fullness, changed: true}
|
||||
|
||||
if new_fullness == 0 do
|
||||
# Pet loses closeness when starving
|
||||
remove_closeness(pet, 1)
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's fullness directly.
|
||||
"""
|
||||
def set_fullness(%__MODULE__{} = pet, fullness) do
|
||||
%{pet | fullness: max(0, min(@max_fullness, fullness)), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the pet's flags (abilities bitmask).
|
||||
"""
|
||||
def set_flags(%__MODULE__{} = pet, flags) do
|
||||
%{pet | flags: flags, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a flag to the pet's abilities.
|
||||
"""
|
||||
def add_flag(%__MODULE__{} = pet, flag) do
|
||||
%{pet | flags: Bitwise.bor(pet.flags, flag), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a flag from the pet's abilities.
|
||||
"""
|
||||
def remove_flag(%__MODULE__{} = pet, flag) do
|
||||
%{pet | flags: Bitwise.band(pet.flags, Bitwise.bnot(flag)), changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet has a specific flag.
|
||||
"""
|
||||
def has_flag?(%__MODULE__{} = pet, flag) do
|
||||
Bitwise.band(pet.flags, flag) == flag
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the pet's position.
|
||||
"""
|
||||
def update_position(%__MODULE__{} = pet, x, y, fh \\ nil, stance \\ nil) do
|
||||
new_position = %{pet.position | x: x, y: y}
|
||||
new_position = if fh, do: %{new_position | fh: fh}, else: new_position
|
||||
|
||||
pet = %{pet | position: new_position}
|
||||
pet = if stance, do: %{pet | stance: stance}, else: pet
|
||||
|
||||
pet
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the seconds left (for time-limited pets).
|
||||
"""
|
||||
def set_seconds_left(%__MODULE__{} = pet, seconds) do
|
||||
%{pet | seconds_left: seconds, changed: true}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decreases seconds left for time-limited pets.
|
||||
Returns {:expired, pet} if time runs out, {:ok, pet} otherwise.
|
||||
"""
|
||||
def tick_seconds(%__MODULE__{} = pet) do
|
||||
if pet.seconds_left > 0 do
|
||||
new_seconds = pet.seconds_left - 1
|
||||
pet = %{pet | seconds_left: new_seconds, changed: true}
|
||||
|
||||
if new_seconds == 0 do
|
||||
{:expired, pet}
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
else
|
||||
{:ok, pet}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks the pet as saved (clears changed flag).
|
||||
"""
|
||||
def mark_saved(%__MODULE__{} = pet) do
|
||||
%{pet | changed: false}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the pet can consume a specific food item.
|
||||
"""
|
||||
def can_consume?(%__MODULE__{} = pet, item_id) do
|
||||
# Different pets can eat different foods
|
||||
# This would check against item data for valid pet foods
|
||||
item_id >= 5_120_000 and item_id < 5_130_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the pet's hunger rate (how fast fullness decreases).
|
||||
Based on pet item ID.
|
||||
"""
|
||||
def get_hunger(%__MODULE__{} = pet) do
|
||||
PetData.get_hunger(pet.pet_item_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the pet's progress to next level as a percentage.
|
||||
"""
|
||||
def level_progress(%__MODULE__{} = pet) do
|
||||
current_req = PetData.closeness_for_level(pet.level)
|
||||
next_req = PetData.closeness_for_level(pet.level + 1)
|
||||
|
||||
if next_req == current_req do
|
||||
100
|
||||
else
|
||||
progress = pet.closeness - current_req
|
||||
needed = next_req - current_req
|
||||
trunc(progress / needed * 100)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts pet to a map for database storage.
|
||||
"""
|
||||
def to_db_map(%__MODULE__{} = pet) do
|
||||
%{
|
||||
petid: pet.unique_id,
|
||||
name: pet.name,
|
||||
level: pet.level,
|
||||
closeness: pet.closeness,
|
||||
fullness: pet.fullness,
|
||||
seconds: pet.seconds_left,
|
||||
flags: pet.flags
|
||||
}
|
||||
end
|
||||
end
|
||||
535
lib/odinsea/game/pet_data.ex
Normal file
535
lib/odinsea/game/pet_data.ex
Normal file
@@ -0,0 +1,535 @@
|
||||
defmodule Odinsea.Game.PetData do
|
||||
@moduledoc """
|
||||
Pet data definitions and lookup functions.
|
||||
Ported from src/client/inventory/PetDataFactory.java
|
||||
and src/server/MapleItemInformationProvider.java (pet methods)
|
||||
and src/constants/GameConstants.java (closeness array)
|
||||
|
||||
Provides:
|
||||
- Pet command data (probability and closeness increase for each command)
|
||||
- Hunger rates per pet
|
||||
- Closeness needed for each level
|
||||
- Pet flag definitions (abilities)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Pet Commands
|
||||
# ============================================================================
|
||||
|
||||
# Command data structure: {probability, closeness_increase}
|
||||
# Probability is 0-100 representing % chance of success
|
||||
# Default commands for pets without specific data
|
||||
@default_commands %{
|
||||
0 => {90, 1}, # Default command 0
|
||||
1 => {90, 1}, # Default command 1
|
||||
2 => {80, 2}, # Default command 2
|
||||
3 => {70, 2}, # Default command 3
|
||||
4 => {60, 3}, # Default command 4
|
||||
5 => {50, 3} # Default command 5
|
||||
}
|
||||
|
||||
# Pet-specific command overrides
|
||||
# Format: pet_item_id => %{command_id => {probability, closeness_increase}}
|
||||
@pet_commands %{
|
||||
# Brown Kitty (5000000)
|
||||
5_000_000 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Black Kitty (5000001)
|
||||
5_000_001 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Panda (5000002)
|
||||
5_000_002 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Brown Puppy (5000003)
|
||||
5_000_003 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Beagle (5000004)
|
||||
5_000_004 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Pink Bunny (5000005)
|
||||
5_000_005 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Husky (5000006)
|
||||
5_000_006 => %{
|
||||
0 => {95, 1},
|
||||
1 => {90, 1},
|
||||
2 => {85, 2},
|
||||
3 => {80, 2},
|
||||
4 => {75, 3}
|
||||
},
|
||||
# Dalmation (5000007)
|
||||
5_000_007 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Baby Dragon (5000008 - 5000013)
|
||||
5_000_008 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_009 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_010 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_011 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_012 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
5_000_013 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3},
|
||||
5 => {60, 4}
|
||||
},
|
||||
# Jr. Balrog (5000014)
|
||||
5_000_014 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3},
|
||||
5 => {55, 4}
|
||||
},
|
||||
# White Tiger (5000015)
|
||||
5_000_015 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Penguin (5000016)
|
||||
5_000_016 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Jr. Yeti (5000017)
|
||||
5_000_017 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Golden Pig (5000018)
|
||||
5_000_018 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Robot (5000019)
|
||||
5_000_019 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Elf (5000020)
|
||||
5_000_020 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Pandas (5000021, 5000022)
|
||||
5_000_021 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
5_000_022 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Ghost (5000023)
|
||||
5_000_023 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Jr. Reaper (5000024)
|
||||
5_000_024 => %{
|
||||
0 => {85, 1},
|
||||
1 => {80, 1},
|
||||
2 => {75, 2},
|
||||
3 => {70, 2},
|
||||
4 => {65, 3}
|
||||
},
|
||||
# Mini Yeti (5000025)
|
||||
5_000_025 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# Kino (5000026)
|
||||
5_000_026 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
},
|
||||
# White Tiger (5000027)
|
||||
5_000_027 => %{
|
||||
0 => {90, 1},
|
||||
1 => {85, 1},
|
||||
2 => {80, 2},
|
||||
3 => {75, 2},
|
||||
4 => {70, 3}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Closeness Needed Per Level
|
||||
# ============================================================================
|
||||
|
||||
# Cumulative closeness needed for each level (index 0 = level 1)
|
||||
@closeness_levels [
|
||||
0, # Level 1
|
||||
1, # Level 2
|
||||
3, # Level 3
|
||||
6, # Level 4
|
||||
14, # Level 5
|
||||
31, # Level 6
|
||||
60, # Level 7
|
||||
108, # Level 8
|
||||
181, # Level 9
|
||||
287, # Level 10
|
||||
434, # Level 11
|
||||
632, # Level 12
|
||||
891, # Level 13
|
||||
1224, # Level 14
|
||||
1642, # Level 15
|
||||
2161, # Level 16
|
||||
2793, # Level 17
|
||||
3557, # Level 18
|
||||
4467, # Level 19
|
||||
5542, # Level 20
|
||||
6801, # Level 21
|
||||
8263, # Level 22
|
||||
9950, # Level 23
|
||||
11882, # Level 24
|
||||
14084, # Level 25
|
||||
16578, # Level 26
|
||||
19391, # Level 27
|
||||
22547, # Level 28
|
||||
26074, # Level 29
|
||||
30000 # Level 30 (Max)
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Hunger Rates
|
||||
# ============================================================================
|
||||
|
||||
# Default hunger rate (fullness lost per tick)
|
||||
@default_hunger 10
|
||||
|
||||
# Pet-specific hunger rates
|
||||
@hunger_rates %{
|
||||
# Event/special pets have higher hunger rates
|
||||
5_000_054 => 5, # Time-limited pets
|
||||
5_000_067 => 5, # Permanent pet (slower hunger)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Pet Flags (Abilities)
|
||||
# ============================================================================
|
||||
|
||||
defmodule PetFlag do
|
||||
@moduledoc """
|
||||
Pet ability flags (ported from MaplePet.PetFlag enum).
|
||||
These are bitflags that can be combined.
|
||||
"""
|
||||
|
||||
# Flag values
|
||||
@item_pickup 0x01
|
||||
@expand_pickup 0x02
|
||||
@auto_pickup 0x04
|
||||
@unpickable 0x08
|
||||
@leftover_pickup 0x10
|
||||
@hp_charge 0x20
|
||||
@mp_charge 0x40
|
||||
@pet_buff 0x80
|
||||
@pet_draw 0x100
|
||||
@pet_dialogue 0x200
|
||||
|
||||
def item_pickup, do: @item_pickup
|
||||
def expand_pickup, do: @expand_pickup
|
||||
def auto_pickup, do: @auto_pickup
|
||||
def unpickable, do: @unpickable
|
||||
def leftover_pickup, do: @leftover_pickup
|
||||
def hp_charge, do: @hp_charge
|
||||
def mp_charge, do: @mp_charge
|
||||
def pet_buff, do: @pet_buff
|
||||
def pet_draw, do: @pet_draw
|
||||
def pet_dialogue, do: @pet_dialogue
|
||||
|
||||
# Item IDs that add each flag
|
||||
@item_to_flag %{
|
||||
5_190_000 => @item_pickup,
|
||||
5_190_001 => @hp_charge,
|
||||
5_190_002 => @expand_pickup,
|
||||
5_190_003 => @auto_pickup,
|
||||
5_190_004 => @leftover_pickup,
|
||||
5_190_005 => @unpickable,
|
||||
5_190_006 => @mp_charge,
|
||||
5_190_007 => @pet_draw,
|
||||
5_190_008 => @pet_dialogue,
|
||||
# 1000-series items also add flags
|
||||
5_191_000 => @item_pickup,
|
||||
5_191_001 => @hp_charge,
|
||||
5_191_002 => @expand_pickup,
|
||||
5_191_003 => @auto_pickup,
|
||||
5_191_004 => @leftover_pickup
|
||||
}
|
||||
|
||||
@doc """
|
||||
Gets the flag value for an item ID.
|
||||
"""
|
||||
def get_by_item_id(item_id) do
|
||||
Map.get(@item_to_flag, item_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a human-readable name for a flag.
|
||||
"""
|
||||
def name(flag) do
|
||||
case flag do
|
||||
@item_pickup -> "pickupItem"
|
||||
@expand_pickup -> "longRange"
|
||||
@auto_pickup -> "dropSweep"
|
||||
@unpickable -> "ignorePickup"
|
||||
@leftover_pickup -> "pickupAll"
|
||||
@hp_charge -> "consumeHP"
|
||||
@mp_charge -> "consumeMP"
|
||||
@pet_buff -> "autoBuff"
|
||||
@pet_draw -> "recall"
|
||||
@pet_dialogue -> "autoSpeaking"
|
||||
_ -> "unknown"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Public API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Gets the closeness needed for a specific level.
|
||||
Returns the cumulative closeness required to reach that level.
|
||||
"""
|
||||
def closeness_for_level(level) when level >= 1 and level <= 30 do
|
||||
Enum.at(@closeness_levels, level - 1, 30_000)
|
||||
end
|
||||
|
||||
def closeness_for_level(level) when level > 30, do: 30_000
|
||||
def closeness_for_level(_level), do: 0
|
||||
|
||||
@doc """
|
||||
Gets pet command data (probability and closeness increase).
|
||||
Returns {probability, closeness_increase} or nil if command doesn't exist.
|
||||
"""
|
||||
def get_pet_command(pet_item_id, command_id) do
|
||||
commands = Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
Map.get(commands, command_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random pet command for the pet.
|
||||
Used when player uses the "Random Pet Command" feature.
|
||||
Returns {command_id, {probability, closeness_increase}} or nil.
|
||||
"""
|
||||
def get_random_pet_command(pet_item_id) do
|
||||
commands = Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
|
||||
if Enum.empty?(commands) do
|
||||
nil
|
||||
else
|
||||
{command_id, data} = Enum.random(commands)
|
||||
{command_id, data}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the hunger rate for a pet (how fast fullness decreases).
|
||||
Lower values mean slower hunger.
|
||||
"""
|
||||
def get_hunger(pet_item_id) do
|
||||
Map.get(@hunger_rates, pet_item_id, @default_hunger)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default name for a pet based on its item ID.
|
||||
"""
|
||||
def get_default_pet_name(pet_item_id) do
|
||||
# Map of pet item IDs to their default names
|
||||
names = %{
|
||||
5_000_000 => "Brown Kitty",
|
||||
5_000_001 => "Black Kitty",
|
||||
5_000_002 => "Panda",
|
||||
5_000_003 => "Brown Puppy",
|
||||
5_000_004 => "Beagle",
|
||||
5_000_005 => "Pink Bunny",
|
||||
5_000_006 => "Husky",
|
||||
5_000_007 => "Dalmation",
|
||||
5_000_008 => "Baby Dragon (Red)",
|
||||
5_000_009 => "Baby Dragon (Blue)",
|
||||
5_000_010 => "Baby Dragon (Green)",
|
||||
5_000_011 => "Baby Dragon (Black)",
|
||||
5_000_012 => "Baby Dragon (Gold)",
|
||||
5_000_013 => "Baby Dragon (Purple)",
|
||||
5_000_014 => "Jr. Balrog",
|
||||
5_000_015 => "White Tiger",
|
||||
5_000_016 => "Penguin",
|
||||
5_000_017 => "Jr. Yeti",
|
||||
5_000_018 => "Golden Pig",
|
||||
5_000_019 => "Robo",
|
||||
5_000_020 => "Fairy",
|
||||
5_000_021 => "Panda (White)",
|
||||
5_000_022 => "Panda (Pink)",
|
||||
5_000_023 => "Ghost",
|
||||
5_000_024 => "Jr. Reaper",
|
||||
5_000_025 => "Mini Yeti",
|
||||
5_000_026 => "Kino",
|
||||
5_000_027 => "White Tiger (Striped)"
|
||||
}
|
||||
|
||||
Map.get(names, pet_item_id, "Pet")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item ID is a pet egg (can be hatched into a pet).
|
||||
"""
|
||||
def pet_egg?(item_id) do
|
||||
# Pet eggs are in range 5000000-5000100
|
||||
item_id >= 5_000_000 and item_id < 5_000_100
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item ID is pet food.
|
||||
"""
|
||||
def pet_food?(item_id) do
|
||||
# Pet food items are in range 2120000-2130000
|
||||
item_id >= 2_120_000 and item_id < 2_130_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the food value (fullness restored) for a pet food item.
|
||||
"""
|
||||
def get_food_value(item_id) do
|
||||
# Standard pet food restores 30 fullness
|
||||
if pet_food?(item_id) do
|
||||
30
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets pet equip slot mappings.
|
||||
Pets can equip special items that give them abilities.
|
||||
"""
|
||||
def pet_equip_slots do
|
||||
%{
|
||||
0 => :hat,
|
||||
1 => :saddle,
|
||||
2 => :decor
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item can be equipped by a pet.
|
||||
"""
|
||||
def pet_equip?(item_id) do
|
||||
# Pet equipment is in range 1802000-1803000
|
||||
item_id >= 1_802_000 and item_id < 1_803_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all available pet commands for a pet.
|
||||
"""
|
||||
def list_pet_commands(pet_item_id) do
|
||||
Map.get(@pet_commands, pet_item_id, @default_commands)
|
||||
end
|
||||
end
|
||||
530
lib/odinsea/game/player_shop.ex
Normal file
530
lib/odinsea/game/player_shop.ex
Normal file
@@ -0,0 +1,530 @@
|
||||
defmodule Odinsea.Game.PlayerShop do
|
||||
@moduledoc """
|
||||
Player-owned shop (mushroom shop) system.
|
||||
Ported from src/server/shops/MaplePlayerShop.java
|
||||
|
||||
Player shops allow players to:
|
||||
- Open a shop with a shop permit item
|
||||
- List items for sale with prices
|
||||
- Allow other players to browse and buy
|
||||
- Support up to 3 visitors at once
|
||||
- Can ban unwanted visitors
|
||||
|
||||
Shop lifecycle:
|
||||
1. Owner creates shop with description
|
||||
2. Owner adds items to sell
|
||||
3. Owner opens shop (becomes visible on map)
|
||||
4. Visitors can enter and buy items
|
||||
5. Owner can close shop (returns unsold items)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{ShopItem, Item, Equip}
|
||||
|
||||
# Shop type constant
|
||||
@shop_type 2
|
||||
|
||||
# Maximum visitors (excluding owner)
|
||||
@max_visitors 3
|
||||
|
||||
# Struct for the shop state
|
||||
defstruct [
|
||||
:id,
|
||||
:owner_id,
|
||||
:owner_account_id,
|
||||
:owner_name,
|
||||
:item_id,
|
||||
:description,
|
||||
:password,
|
||||
:map_id,
|
||||
:channel,
|
||||
:position,
|
||||
:meso,
|
||||
:items,
|
||||
:visitors,
|
||||
:visitor_names,
|
||||
:banned_list,
|
||||
:open,
|
||||
:available,
|
||||
:bought_items,
|
||||
:bought_count
|
||||
]
|
||||
|
||||
@doc """
|
||||
Starts a new player shop GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
shop_id = Keyword.fetch!(opts, :id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(shop_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new player shop.
|
||||
"""
|
||||
def create(opts) do
|
||||
%__MODULE__{
|
||||
id: opts[:id] || generate_id(),
|
||||
owner_id: opts[:owner_id],
|
||||
owner_account_id: opts[:owner_account_id],
|
||||
owner_name: opts[:owner_name],
|
||||
item_id: opts[:item_id],
|
||||
description: opts[:description] || "",
|
||||
password: opts[:password] || "",
|
||||
map_id: opts[:map_id],
|
||||
channel: opts[:channel],
|
||||
position: opts[:position],
|
||||
meso: 0,
|
||||
items: [],
|
||||
visitors: %{},
|
||||
visitor_names: [],
|
||||
banned_list: [],
|
||||
open: false,
|
||||
available: false,
|
||||
bought_items: [],
|
||||
bought_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the shop type (2 = player shop).
|
||||
"""
|
||||
def shop_type, do: @shop_type
|
||||
|
||||
@doc """
|
||||
Gets the current shop state.
|
||||
"""
|
||||
def get_state(shop_pid) when is_pid(shop_pid) do
|
||||
GenServer.call(shop_pid, :get_state)
|
||||
end
|
||||
|
||||
def get_state(shop_id) do
|
||||
case lookup(shop_id) do
|
||||
{:ok, pid} -> get_state(pid)
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Looks up a shop by ID.
|
||||
"""
|
||||
def lookup(shop_id) do
|
||||
case Registry.lookup(Odinsea.ShopRegistry, shop_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an item to the shop.
|
||||
"""
|
||||
def add_item(shop_id, %ShopItem{} = item) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:add_item, item})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes an item from the shop by slot.
|
||||
"""
|
||||
def remove_item(shop_id, slot) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:remove_item, slot})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys an item from the shop.
|
||||
Returns {:ok, item, price} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def buy_item(shop_id, slot, quantity, buyer_id, buyer_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:buy_item, slot, quantity, buyer_id, buyer_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a visitor to the shop.
|
||||
Returns the visitor slot (1-3) or {:error, :full}.
|
||||
"""
|
||||
def add_visitor(shop_id, character_id, character_pid) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:add_visitor, character_id, character_pid})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a visitor from the shop.
|
||||
"""
|
||||
def remove_visitor(shop_id, character_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:remove_visitor, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Bans a player from the shop.
|
||||
"""
|
||||
def ban_player(shop_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:ban_player, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is banned from the shop.
|
||||
"""
|
||||
def is_banned?(shop_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:is_banned, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the shop open status.
|
||||
"""
|
||||
def set_open(shop_id, open) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_open, open})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the shop available status (visible on map).
|
||||
"""
|
||||
def set_available(shop_id, available) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_available, available})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a free visitor slot.
|
||||
Returns slot number (1-3) or nil if full.
|
||||
"""
|
||||
def get_free_slot(shop_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, :get_free_slot)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the visitor slot for a character.
|
||||
Returns slot number (0 for owner, 1-3 for visitors, -1 if not found).
|
||||
"""
|
||||
def get_visitor_slot(shop_id, character_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:get_visitor_slot, character_id})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the character is the owner.
|
||||
"""
|
||||
def is_owner?(shop_id, character_id, character_name) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:is_owner, character_id, character_name})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Closes the shop and returns unsold items.
|
||||
"""
|
||||
def close_shop(shop_id, save_items \\ false) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:close_shop, save_items})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current meso amount in the shop.
|
||||
"""
|
||||
def get_meso(shop_id) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, :get_meso)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the meso amount in the shop.
|
||||
"""
|
||||
def set_meso(shop_id, meso) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.call(pid, {:set_meso, meso})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all visitors.
|
||||
"""
|
||||
def broadcast_to_visitors(shop_id, packet, include_owner \\ true) do
|
||||
with {:ok, pid} <- lookup(shop_id) do
|
||||
GenServer.cast(pid, {:broadcast, packet, include_owner})
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = create(opts)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_item, item}, _from, state) do
|
||||
new_items = state.items ++ [item]
|
||||
{:reply, :ok, %{state | items: new_items}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_item, slot}, _from, state) do
|
||||
if slot >= 0 and slot < length(state.items) do
|
||||
{removed, new_items} = List.pop_at(state.items, slot)
|
||||
{:reply, {:ok, removed}, %{state | items: new_items}}
|
||||
else
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:buy_item, slot, quantity, buyer_id, buyer_name}, _from, state) do
|
||||
cond do
|
||||
slot < 0 or slot >= length(state.items) ->
|
||||
{:reply, {:error, :invalid_slot}, state}
|
||||
|
||||
true ->
|
||||
shop_item = Enum.at(state.items, slot)
|
||||
|
||||
cond do
|
||||
shop_item.bundles < quantity ->
|
||||
{:reply, {:error, :not_enough_stock}, state}
|
||||
|
||||
true ->
|
||||
# Create bought item record
|
||||
price = shop_item.price * quantity
|
||||
|
||||
bought_record = %{
|
||||
item_id: shop_item.item.item_id,
|
||||
quantity: quantity,
|
||||
total_price: price,
|
||||
buyer: buyer_name
|
||||
}
|
||||
|
||||
# Reduce bundles
|
||||
updated_item = ShopItem.reduce_bundles(shop_item, quantity)
|
||||
|
||||
# Update items list
|
||||
new_items =
|
||||
if ShopItem.sold_out?(updated_item) do
|
||||
List.delete_at(state.items, slot)
|
||||
else
|
||||
List.replace_at(state.items, slot, updated_item)
|
||||
end
|
||||
|
||||
# Create item for buyer
|
||||
buyer_item = ShopItem.create_buyer_item(shop_item, quantity)
|
||||
|
||||
# Update state
|
||||
new_bought_items = [bought_record | state.bought_items]
|
||||
new_bought_count = state.bought_count + 1
|
||||
|
||||
# Check if all items sold
|
||||
should_close = new_bought_count >= length(state.items) and new_items == []
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| items: new_items,
|
||||
bought_items: new_bought_items,
|
||||
bought_count: new_bought_count
|
||||
}
|
||||
|
||||
if should_close do
|
||||
{:reply, {:ok, buyer_item, price, :close}, new_state}
|
||||
else
|
||||
{:reply, {:ok, buyer_item, price, :continue}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
|
||||
# Check if already a visitor
|
||||
if Map.has_key?(state.visitors, character_id) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, {:ok, slot}, state}
|
||||
else
|
||||
# Find free slot
|
||||
case find_free_slot(state) do
|
||||
nil ->
|
||||
{:reply, {:error, :full}, state}
|
||||
|
||||
slot ->
|
||||
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
|
||||
|
||||
# Track visitor name for history
|
||||
new_visitor_names =
|
||||
if character_id != state.owner_id do
|
||||
[character_id | state.visitor_names]
|
||||
else
|
||||
state.visitor_names
|
||||
end
|
||||
|
||||
new_state = %{state | visitors: new_visitors, visitor_names: new_visitor_names}
|
||||
{:reply, {:ok, slot}, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_visitor, character_id}, _from, state) do
|
||||
new_visitors = Map.delete(state.visitors, character_id)
|
||||
{:reply, :ok, %{state | visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:ban_player, character_name}, _from, state) do
|
||||
# Add to banned list
|
||||
new_banned =
|
||||
if character_name in state.banned_list do
|
||||
state.banned_list
|
||||
else
|
||||
[character_name | state.banned_list]
|
||||
end
|
||||
|
||||
# Find and remove if currently visiting
|
||||
visitor_to_remove =
|
||||
Enum.find(state.visitors, fn {_id, data} ->
|
||||
# This would need the character name, which we don't have in the state
|
||||
# For now, just ban from future visits
|
||||
false
|
||||
end)
|
||||
|
||||
new_visitors =
|
||||
case visitor_to_remove do
|
||||
{id, _} -> Map.delete(state.visitors, id)
|
||||
nil -> state.visitors
|
||||
end
|
||||
|
||||
{:reply, :ok, %{state | banned_list: new_banned, visitors: new_visitors}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_banned, character_name}, _from, state) do
|
||||
{:reply, character_name in state.banned_list, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_open, open}, _from, state) do
|
||||
{:reply, :ok, %{state | open: open}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_available, available}, _from, state) do
|
||||
{:reply, :ok, %{state | available: available}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_free_slot, _from, state) do
|
||||
{:reply, find_free_slot(state), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_visitor_slot, character_id}, _from, state) do
|
||||
slot = get_slot_for_character(state, character_id)
|
||||
{:reply, slot, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:is_owner, character_id, character_name}, _from, state) do
|
||||
is_owner = character_id == state.owner_id and character_name == state.owner_name
|
||||
{:reply, is_owner, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:close_shop, _save_items}, _from, state) do
|
||||
# Remove all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:shop_closed, state.id})
|
||||
end)
|
||||
|
||||
# Return unsold items to owner
|
||||
unsold_items =
|
||||
Enum.filter(state.items, fn item -> item.bundles > 0 end)
|
||||
|> Enum.map(fn shop_item ->
|
||||
item = shop_item.item
|
||||
total_qty = shop_item.bundles * item.quantity
|
||||
%{item | quantity: total_qty}
|
||||
end)
|
||||
|
||||
{:reply, {:ok, unsold_items, state.meso}, %{state | open: false, available: false}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_meso, _from, state) do
|
||||
{:reply, state.meso, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_meso, meso}, _from, state) do
|
||||
{:reply, :ok, %{state | meso: meso}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet, include_owner}, state) do
|
||||
# Broadcast to all visitors
|
||||
Enum.each(state.visitors, fn {_id, data} ->
|
||||
send(data.pid, {:shop_packet, packet})
|
||||
end)
|
||||
|
||||
# Optionally broadcast to owner
|
||||
if include_owner do
|
||||
# Owner would receive via their own channel
|
||||
:ok
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(shop_id) do
|
||||
{:via, Registry, {Odinsea.ShopRegistry, shop_id}}
|
||||
end
|
||||
|
||||
defp generate_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp find_free_slot(state) do
|
||||
used_slots = Map.values(state.visitors) |> Enum.map(& &1.slot)
|
||||
|
||||
Enum.find(1..@max_visitors, fn slot ->
|
||||
slot not in used_slots
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_slot_for_character(state, character_id) do
|
||||
cond do
|
||||
character_id == state.owner_id ->
|
||||
0
|
||||
|
||||
true ->
|
||||
case Map.get(state.visitors, character_id) do
|
||||
nil -> -1
|
||||
data -> data.slot
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
580
lib/odinsea/game/quest.ex
Normal file
580
lib/odinsea/game/quest.ex
Normal file
@@ -0,0 +1,580 @@
|
||||
defmodule Odinsea.Game.Quest do
|
||||
@moduledoc """
|
||||
Quest Information Provider - loads and caches quest data.
|
||||
|
||||
This module loads quest metadata, requirements, and actions from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Data is cached in ETS for fast lookups.
|
||||
|
||||
## Quest Structure
|
||||
|
||||
A quest consists of:
|
||||
- **ID**: Unique quest identifier
|
||||
- **Name**: Quest display name
|
||||
- **Start Requirements**: Conditions to start the quest (level, items, completed quests, etc.)
|
||||
- **Complete Requirements**: Conditions to complete the quest (mob kills, items, etc.)
|
||||
- **Start Actions**: Rewards/actions when starting the quest
|
||||
- **Complete Actions**: Rewards/actions when completing the quest (exp, meso, items, etc.)
|
||||
|
||||
## Quest Flags
|
||||
|
||||
- `auto_start`: Quest starts automatically when requirements are met
|
||||
- `auto_complete`: Quest completes automatically when requirements are met
|
||||
- `auto_pre_complete`: Auto-complete without NPC interaction
|
||||
- `repeatable`: Quest can be repeated
|
||||
- `blocked`: Quest is disabled/blocked
|
||||
- `has_no_npc`: Quest has no associated NPC
|
||||
- `option`: Quest has multiple start options
|
||||
- `custom_end`: Quest has a custom end script
|
||||
- `scripted_start`: Quest has a custom start script
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{QuestRequirement, QuestAction}
|
||||
|
||||
# ETS table names
|
||||
@quest_cache :odinsea_quest_cache
|
||||
@quest_names :odinsea_quest_names
|
||||
|
||||
# Data file paths (relative to priv directory)
|
||||
@quest_data_file "data/quests.json"
|
||||
@quest_strings_file "data/quest_strings.json"
|
||||
|
||||
defmodule QuestInfo do
|
||||
@moduledoc "Complete quest information structure"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
quest_id: integer(),
|
||||
name: String.t(),
|
||||
start_requirements: [Odinsea.Game.QuestRequirement.t()],
|
||||
complete_requirements: [Odinsea.Game.QuestRequirement.t()],
|
||||
start_actions: [Odinsea.Game.QuestAction.t()],
|
||||
complete_actions: [Odinsea.Game.QuestAction.t()],
|
||||
auto_start: boolean(),
|
||||
auto_complete: boolean(),
|
||||
auto_pre_complete: boolean(),
|
||||
repeatable: boolean(),
|
||||
blocked: boolean(),
|
||||
has_no_npc: boolean(),
|
||||
option: boolean(),
|
||||
custom_end: boolean(),
|
||||
scripted_start: boolean(),
|
||||
view_medal_item: integer(),
|
||||
selected_skill_id: integer(),
|
||||
relevant_mobs: %{integer() => integer()}
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:quest_id,
|
||||
:name,
|
||||
start_requirements: [],
|
||||
complete_requirements: [],
|
||||
start_actions: [],
|
||||
complete_actions: [],
|
||||
auto_start: false,
|
||||
auto_complete: false,
|
||||
auto_pre_complete: false,
|
||||
repeatable: false,
|
||||
blocked: false,
|
||||
has_no_npc: true,
|
||||
option: false,
|
||||
custom_end: false,
|
||||
scripted_start: false,
|
||||
view_medal_item: 0,
|
||||
selected_skill_id: 0,
|
||||
relevant_mobs: %{}
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the Quest GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets quest information by quest ID"
|
||||
@spec get_quest(integer()) :: QuestInfo.t() | nil
|
||||
def get_quest(quest_id) do
|
||||
case :ets.lookup(@quest_cache, quest_id) do
|
||||
[{^quest_id, quest}] -> quest
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets quest name by quest ID"
|
||||
@spec get_name(integer()) :: String.t() | nil
|
||||
def get_name(quest_id) do
|
||||
case :ets.lookup(@quest_names, quest_id) do
|
||||
[{^quest_id, name}] -> name
|
||||
[] -> "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets all loaded quest IDs"
|
||||
@spec get_all_quest_ids() :: [integer()]
|
||||
def get_all_quest_ids do
|
||||
:ets.select(@quest_cache, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Checks if a quest exists"
|
||||
@spec quest_exists?(integer()) :: boolean()
|
||||
def quest_exists?(quest_id) do
|
||||
:ets.member(@quest_cache, quest_id)
|
||||
end
|
||||
|
||||
@doc "Gets start requirements for a quest"
|
||||
@spec get_start_requirements(integer()) :: [Odinsea.Game.QuestRequirement.t()]
|
||||
def get_start_requirements(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> []
|
||||
quest -> quest.start_requirements
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets complete requirements for a quest"
|
||||
@spec get_complete_requirements(integer()) :: [Odinsea.Game.QuestRequirement.t()]
|
||||
def get_complete_requirements(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> []
|
||||
quest -> quest.complete_requirements
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets complete actions (rewards) for a quest"
|
||||
@spec get_complete_actions(integer()) :: [Odinsea.Game.QuestAction.t()]
|
||||
def get_complete_actions(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> []
|
||||
quest -> quest.complete_actions
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets relevant mobs for a quest (mob_id => count_required)"
|
||||
@spec get_relevant_mobs(integer()) :: %{integer() => integer()}
|
||||
def get_relevant_mobs(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> %{}
|
||||
quest -> quest.relevant_mobs
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if quest can be started by a character"
|
||||
@spec can_start?(integer(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def can_start?(quest_id, character) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest -> check_requirements(quest.start_requirements, character)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if quest can be completed by a character"
|
||||
@spec can_complete?(integer(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def can_complete?(quest_id, character) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest -> check_requirements(quest.complete_requirements, character)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if a quest is auto-start"
|
||||
@spec auto_start?(integer()) :: boolean()
|
||||
def auto_start?(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest -> quest.auto_start
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if a quest is auto-complete"
|
||||
@spec auto_complete?(integer()) :: boolean()
|
||||
def auto_complete?(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest -> quest.auto_complete
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if a quest is repeatable"
|
||||
@spec repeatable?(integer()) :: boolean()
|
||||
def repeatable?(quest_id) do
|
||||
case get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest -> quest.repeatable
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Reloads quest data from files"
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@quest_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@quest_names, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_quest_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading quest data...")
|
||||
load_quest_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_quest_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Try to load from JSON files
|
||||
# If files don't exist, create minimal fallback data
|
||||
load_quest_strings(Path.join(priv_dir, @quest_strings_file))
|
||||
load_quests(Path.join(priv_dir, @quest_data_file))
|
||||
|
||||
quest_count = :ets.info(@quest_cache, :size)
|
||||
Logger.info("Loaded #{quest_count} quest definitions")
|
||||
end
|
||||
|
||||
defp load_quest_strings(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, data} when is_map(data) ->
|
||||
Enum.each(data, fn {id_str, name} ->
|
||||
case Integer.parse(id_str) do
|
||||
{quest_id, ""} -> :ets.insert(@quest_names, {quest_id, name})
|
||||
_ -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse quest strings JSON: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Quest strings file not found: #{file_path}, using fallback data")
|
||||
create_fallback_strings()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read quest strings: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_quests(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, quests} when is_list(quests) ->
|
||||
Enum.each(quests, fn quest_data ->
|
||||
quest = build_quest_from_json(quest_data)
|
||||
:ets.insert(@quest_cache, {quest.quest_id, quest})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse quests JSON: #{inspect(reason)}")
|
||||
create_fallback_quests()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Quests file not found: #{file_path}, using fallback data")
|
||||
create_fallback_quests()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read quests: #{inspect(reason)}")
|
||||
create_fallback_quests()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_quest_from_json(data) do
|
||||
quest_id = Map.get(data, :quest_id, Map.get(data, :id, 0))
|
||||
|
||||
# Build requirements from JSON
|
||||
start_reqs =
|
||||
data
|
||||
|> Map.get(:start_requirements, [])
|
||||
|> Enum.map(&QuestRequirement.from_map/1)
|
||||
|
||||
complete_reqs =
|
||||
data
|
||||
|> Map.get(:complete_requirements, [])
|
||||
|> Enum.map(&QuestRequirement.from_map/1)
|
||||
|
||||
# Build actions from JSON
|
||||
start_actions =
|
||||
data
|
||||
|> Map.get(:start_actions, [])
|
||||
|> Enum.map(&QuestAction.from_map/1)
|
||||
|
||||
complete_actions =
|
||||
data
|
||||
|> Map.get(:complete_actions, [])
|
||||
|> Enum.map(&QuestAction.from_map/1)
|
||||
|
||||
# Extract relevant mobs from mob requirements
|
||||
relevant_mobs = extract_relevant_mobs(complete_reqs)
|
||||
|
||||
%QuestInfo{
|
||||
quest_id: quest_id,
|
||||
name: Map.get(data, :name, get_name(quest_id)),
|
||||
start_requirements: start_reqs,
|
||||
complete_requirements: complete_reqs,
|
||||
start_actions: start_actions,
|
||||
complete_actions: complete_actions,
|
||||
auto_start: Map.get(data, :auto_start, false),
|
||||
auto_complete: Map.get(data, :auto_complete, false),
|
||||
auto_pre_complete: Map.get(data, :auto_pre_complete, false),
|
||||
repeatable: Map.get(data, :repeatable, false),
|
||||
blocked: Map.get(data, :blocked, false),
|
||||
has_no_npc: Map.get(data, :has_no_npc, true),
|
||||
option: Map.get(data, :option, false),
|
||||
custom_end: Map.get(data, :custom_end, false),
|
||||
scripted_start: Map.get(data, :scripted_start, false),
|
||||
view_medal_item: Map.get(data, :view_medal_item, 0),
|
||||
selected_skill_id: Map.get(data, :selected_skill_id, 0),
|
||||
relevant_mobs: relevant_mobs
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_relevant_mobs(requirements) do
|
||||
requirements
|
||||
|> Enum.filter(fn req -> req.type == :mob end)
|
||||
|> Enum.flat_map(fn req -> req.data end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp check_requirements(requirements, character) do
|
||||
Enum.all?(requirements, fn req ->
|
||||
QuestRequirement.check(req, character)
|
||||
end)
|
||||
end
|
||||
|
||||
# Fallback data for basic testing without WZ exports
|
||||
defp create_fallback_strings do
|
||||
# Common beginner quest names
|
||||
fallback_names = %{
|
||||
# Tutorial quests
|
||||
1_000 => "[Required] The New Explorer",
|
||||
1_001 => "[Required] Moving Around",
|
||||
1_002 => "[Required] Attacking Enemies",
|
||||
1_003 => "[Required] Quest and Journal",
|
||||
# Mai's quests (beginner)
|
||||
2_001 => "Mai's First Request",
|
||||
2_002 => "Mai's Second Request",
|
||||
2_003 => "Mai's Final Request",
|
||||
# Job advancement quests
|
||||
10_000 => "The Path of a Warrior",
|
||||
10_001 => "The Path of a Magician",
|
||||
10_002 => "The Path of a Bowman",
|
||||
10_003 => "The Path of a Thief",
|
||||
10_004 => "The Path of a Pirate",
|
||||
# Maple Island quests
|
||||
2_006 => "The Honey Thief",
|
||||
2_007 => "Delivering the Honey",
|
||||
2_008 => "The Missing Child",
|
||||
# Victoria Island quests
|
||||
2_101 => "Pio's Collecting Recycled Goods",
|
||||
2_102 => "Pio's Recycling",
|
||||
2_201 => "Bigg's Secret Collecting",
|
||||
2_202 => "Bigg's Secret Formula",
|
||||
2_203 => "The Mineral Sack",
|
||||
# Explorer quests
|
||||
2_900 => "Explorer of the Hill",
|
||||
2_901 => "Explorer of the Forest",
|
||||
# Medal quests
|
||||
2_9005 => "Victoria Island Explorer",
|
||||
2_9006 => "El Nath Explorer",
|
||||
2_9014 => "Sleepywood Explorer"
|
||||
}
|
||||
|
||||
Enum.each(fallback_names, fn {quest_id, name} ->
|
||||
:ets.insert(@quest_names, {quest_id, name})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_quests do
|
||||
# Mai's First Request - Classic beginner tutorial quest
|
||||
mai_first_request = %QuestInfo{
|
||||
quest_id: 2_001,
|
||||
name: "Mai's First Request",
|
||||
start_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :lvmin,
|
||||
data: 1
|
||||
}
|
||||
],
|
||||
complete_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :item,
|
||||
data: %{2_000_001 => 1}
|
||||
}
|
||||
],
|
||||
start_actions: [],
|
||||
complete_actions: [
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :exp,
|
||||
value: 50
|
||||
},
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :money,
|
||||
value: 100
|
||||
}
|
||||
],
|
||||
auto_start: false,
|
||||
has_no_npc: false
|
||||
}
|
||||
|
||||
# Mai's Second Request
|
||||
mai_second_request = %QuestInfo{
|
||||
quest_id: 2_002,
|
||||
name: "Mai's Second Request",
|
||||
start_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :quest,
|
||||
data: %{2_001 => 2}
|
||||
},
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :lvmin,
|
||||
data: 1
|
||||
}
|
||||
],
|
||||
complete_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :mob,
|
||||
data: %{1_001_001 => 3}
|
||||
}
|
||||
],
|
||||
start_actions: [],
|
||||
complete_actions: [
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :exp,
|
||||
value: 100
|
||||
},
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :money,
|
||||
value: 200
|
||||
},
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :item,
|
||||
value: [
|
||||
%{item_id: 2_000_000, count: 20}
|
||||
]
|
||||
}
|
||||
],
|
||||
auto_start: false,
|
||||
has_no_npc: false
|
||||
}
|
||||
|
||||
# Tutorial Movement Quest
|
||||
tutorial_movement = %QuestInfo{
|
||||
quest_id: 1_001,
|
||||
name: "[Required] Moving Around",
|
||||
start_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :quest,
|
||||
data: %{1_000 => 2}
|
||||
}
|
||||
],
|
||||
complete_requirements: [],
|
||||
start_actions: [],
|
||||
complete_actions: [
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :exp,
|
||||
value: 25
|
||||
}
|
||||
],
|
||||
auto_complete: true,
|
||||
has_no_npc: true
|
||||
}
|
||||
|
||||
# Explorer quest example (Medal)
|
||||
explorer_victoria = %QuestInfo{
|
||||
quest_id: 2_9005,
|
||||
name: "Victoria Island Explorer",
|
||||
start_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :lvmin,
|
||||
data: 15
|
||||
},
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :questComplete,
|
||||
data: 10
|
||||
}
|
||||
],
|
||||
complete_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :fieldEnter,
|
||||
data: [100_000_000, 101_000_000, 102_000_000, 103_000_000, 104_000_000]
|
||||
}
|
||||
],
|
||||
start_actions: [],
|
||||
complete_actions: [
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :exp,
|
||||
value: 500
|
||||
},
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :item,
|
||||
value: [
|
||||
%{item_id: 1_142_005, count: 1, period: 0}
|
||||
]
|
||||
}
|
||||
],
|
||||
has_no_npc: false
|
||||
}
|
||||
|
||||
# Job advancement - Warrior
|
||||
warrior_path = %QuestInfo{
|
||||
quest_id: 10_000,
|
||||
name: "The Path of a Warrior",
|
||||
start_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :lvmin,
|
||||
data: 10
|
||||
},
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :job,
|
||||
data: [0]
|
||||
}
|
||||
],
|
||||
complete_requirements: [
|
||||
%Odinsea.Game.QuestRequirement{
|
||||
type: :fieldEnter,
|
||||
data: [102_000_003]
|
||||
}
|
||||
],
|
||||
start_actions: [],
|
||||
complete_actions: [
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :exp,
|
||||
value: 200
|
||||
},
|
||||
%Odinsea.Game.QuestAction{
|
||||
type: :job,
|
||||
value: 100
|
||||
}
|
||||
],
|
||||
has_no_npc: false
|
||||
}
|
||||
|
||||
# Store fallback quests
|
||||
:ets.insert(@quest_cache, {mai_first_request.quest_id, mai_first_request})
|
||||
:ets.insert(@quest_cache, {mai_second_request.quest_id, mai_second_request})
|
||||
:ets.insert(@quest_cache, {tutorial_movement.quest_id, tutorial_movement})
|
||||
:ets.insert(@quest_cache, {explorer_victoria.quest_id, explorer_victoria})
|
||||
:ets.insert(@quest_cache, {warrior_path.quest_id, warrior_path})
|
||||
end
|
||||
end
|
||||
744
lib/odinsea/game/quest_action.ex
Normal file
744
lib/odinsea/game/quest_action.ex
Normal file
@@ -0,0 +1,744 @@
|
||||
defmodule Odinsea.Game.QuestAction do
|
||||
@moduledoc """
|
||||
Quest Action module - defines rewards and effects for quest completion.
|
||||
|
||||
Actions are executed when:
|
||||
- Starting a quest (start_actions)
|
||||
- Completing a quest (complete_actions)
|
||||
|
||||
## Action Types
|
||||
|
||||
- `:exp` - Experience points reward
|
||||
- `:money` - Meso reward
|
||||
- `:item` - Item rewards (can be job/gender restricted)
|
||||
- `:pop` - Fame reward
|
||||
- `:sp` - Skill points reward
|
||||
- `:skill` - Learn specific skills
|
||||
- `:nextQuest` - Start another quest automatically
|
||||
- `:buffItemID` - Apply buff from item effect
|
||||
- `:infoNumber` - Info quest update
|
||||
- `:quest` - Update other quest states
|
||||
|
||||
## Trait EXP Types
|
||||
|
||||
- `:charmEXP` - Charm trait experience
|
||||
- `:charismaEXP` - Charisma trait experience
|
||||
- `:craftEXP` - Craft (smithing) trait experience
|
||||
- `:insightEXP` - Insight trait experience
|
||||
- `:senseEXP` - Sense trait experience
|
||||
- `:willEXP` - Will trait experience
|
||||
|
||||
## Job Restrictions
|
||||
|
||||
Items and skills can be restricted by job using job encoding:
|
||||
- Bit flags for job categories (Warrior, Magician, Bowman, Thief, Pirate, etc.)
|
||||
- Supports both 5-byte and simple encodings
|
||||
|
||||
## Gender Restrictions
|
||||
|
||||
Items can be restricted by gender:
|
||||
- `0` - Male only
|
||||
- `1` - Female only
|
||||
- `2` - Both (no restriction)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Quest
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: atom(),
|
||||
value: any(),
|
||||
applicable_jobs: [integer()],
|
||||
int_store: integer()
|
||||
}
|
||||
|
||||
defstruct [:type, :value, :applicable_jobs, :int_store]
|
||||
|
||||
defmodule QuestItem do
|
||||
@moduledoc "Quest item reward structure"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item_id: integer(),
|
||||
count: integer(),
|
||||
period: integer(),
|
||||
gender: integer(),
|
||||
job: integer(),
|
||||
job_ex: integer(),
|
||||
prop: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:item_id,
|
||||
:count,
|
||||
period: 0,
|
||||
gender: 2,
|
||||
job: -1,
|
||||
job_ex: -1,
|
||||
prop: -2
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Creates a new quest action"
|
||||
@spec new(atom(), any(), keyword()) :: t()
|
||||
def new(type, value, opts \\ []) do
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
value: value,
|
||||
applicable_jobs: Keyword.get(opts, :applicable_jobs, []),
|
||||
int_store: Keyword.get(opts, :int_store, 0)
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Builds an action from a map (JSON deserialization)"
|
||||
@spec from_map(map()) :: t()
|
||||
def from_map(map) do
|
||||
type = parse_type(Map.get(map, :type, Map.get(map, "type", "undefined")))
|
||||
|
||||
{value, applicable_jobs, int_store} = parse_action_data(type, map)
|
||||
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
value: value,
|
||||
applicable_jobs: applicable_jobs,
|
||||
int_store: int_store
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Parses a WZ action name into an atom"
|
||||
@spec parse_type(String.t() | atom()) :: atom()
|
||||
def parse_type(type) when is_atom(type), do: type
|
||||
|
||||
def parse_type(type_str) when is_binary(type_str) do
|
||||
case String.downcase(type_str) do
|
||||
"exp" -> :exp
|
||||
"item" -> :item
|
||||
"nextquest" -> :nextQuest
|
||||
"money" -> :money
|
||||
"quest" -> :quest
|
||||
"skill" -> :skill
|
||||
"pop" -> :pop
|
||||
"buffitemid" -> :buffItemID
|
||||
"infonumber" -> :infoNumber
|
||||
"sp" -> :sp
|
||||
"charismaexp" -> :charismaEXP
|
||||
"charmexp" -> :charmEXP
|
||||
"willexp" -> :willEXP
|
||||
"insightexp" -> :insightEXP
|
||||
"senseexp" -> :senseEXP
|
||||
"craftexp" -> :craftEXP
|
||||
"job" -> :job
|
||||
_ -> :undefined
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Runs start actions for a quest"
|
||||
@spec run_start(t(), Odinsea.Game.Character.t()) :: Odinsea.Game.Character.t()
|
||||
def run_start(%__MODULE__{} = action, character) do
|
||||
do_run_start(action.type, action, character)
|
||||
end
|
||||
|
||||
@doc "Runs end/complete actions for a quest"
|
||||
@spec run_end(t(), Odinsea.Game.Character.t()) :: Odinsea.Game.Character.t()
|
||||
def run_end(%__MODULE__{} = action, character) do
|
||||
do_run_end(action.type, action, character)
|
||||
end
|
||||
|
||||
@doc "Checks if character can receive this action's rewards (inventory space, etc.)"
|
||||
@spec check_end(t(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def check_end(%__MODULE__{} = action, character) do
|
||||
do_check_end(action.type, action, character)
|
||||
end
|
||||
|
||||
@doc "Checks if an item reward can be given to this character"
|
||||
@spec can_get_item?(QuestItem.t(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def can_get_item?(%QuestItem{} = item, character) do
|
||||
# Check gender restriction
|
||||
gender_ok =
|
||||
if item.gender != 2 && item.gender >= 0 do
|
||||
character_gender = Map.get(character, :gender, 0)
|
||||
item.gender == character_gender
|
||||
else
|
||||
true
|
||||
end
|
||||
|
||||
if not gender_ok do
|
||||
false
|
||||
else
|
||||
# Check job restriction
|
||||
if item.job > 0 do
|
||||
character_job = Map.get(character, :job, 0)
|
||||
job_codes = get_job_by_5byte_encoding(item.job)
|
||||
|
||||
job_found =
|
||||
Enum.any?(job_codes, fn code ->
|
||||
div(code, 100) == div(character_job, 100)
|
||||
end)
|
||||
|
||||
if not job_found and item.job_ex > 0 do
|
||||
job_codes_ex = get_job_by_simple_encoding(item.job_ex)
|
||||
|
||||
job_found =
|
||||
Enum.any?(job_codes_ex, fn code ->
|
||||
rem(div(code, 100), 10) == rem(div(character_job, 100), 10)
|
||||
end)
|
||||
end
|
||||
|
||||
job_found
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets job list from 5-byte encoding"
|
||||
@spec get_job_by_5byte_encoding(integer()) :: [integer()]
|
||||
def get_job_by_5byte_encoding(encoded) do
|
||||
[]
|
||||
|> add_job_if(encoded, 0x1, 0)
|
||||
|> add_job_if(encoded, 0x2, 100)
|
||||
|> add_job_if(encoded, 0x4, 200)
|
||||
|> add_job_if(encoded, 0x8, 300)
|
||||
|> add_job_if(encoded, 0x10, 400)
|
||||
|> add_job_if(encoded, 0x20, 500)
|
||||
|> add_job_if(encoded, 0x400, 1000)
|
||||
|> add_job_if(encoded, 0x800, 1100)
|
||||
|> add_job_if(encoded, 0x1000, 1200)
|
||||
|> add_job_if(encoded, 0x2000, 1300)
|
||||
|> add_job_if(encoded, 0x4000, 1400)
|
||||
|> add_job_if(encoded, 0x8000, 1500)
|
||||
|> add_job_if(encoded, 0x20000, 2001)
|
||||
|> add_job_if(encoded, 0x20000, 2200)
|
||||
|> add_job_if(encoded, 0x100000, 2000)
|
||||
|> add_job_if(encoded, 0x100000, 2001)
|
||||
|> add_job_if(encoded, 0x200000, 2100)
|
||||
|> add_job_if(encoded, 0x400000, 2200)
|
||||
|> add_job_if(encoded, 0x40000000, 3000)
|
||||
|> add_job_if(encoded, 0x40000000, 3200)
|
||||
|> add_job_if(encoded, 0x40000000, 3300)
|
||||
|> add_job_if(encoded, 0x40000000, 3500)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
@doc "Gets job list from simple encoding"
|
||||
@spec get_job_by_simple_encoding(integer()) :: [integer()]
|
||||
def get_job_by_simple_encoding(encoded) do
|
||||
[]
|
||||
|> add_job_if(encoded, 0x1, 200)
|
||||
|> add_job_if(encoded, 0x2, 300)
|
||||
|> add_job_if(encoded, 0x4, 400)
|
||||
|> add_job_if(encoded, 0x8, 500)
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp add_job_if(list, encoded, flag, job) do
|
||||
if Bitwise.band(encoded, flag) != 0 do
|
||||
[job | list]
|
||||
else
|
||||
list
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_action_data(:exp, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:money, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:pop, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:sp, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
|
||||
applicable_jobs =
|
||||
map
|
||||
|> Map.get(:applicable_jobs, Map.get(map, "applicable_jobs", []))
|
||||
|> parse_job_list()
|
||||
|
||||
{int_store, applicable_jobs, int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:item, map) do
|
||||
items =
|
||||
map
|
||||
|> Map.get(:value, Map.get(map, "value", []))
|
||||
|> parse_item_list()
|
||||
|
||||
{items, [], 0}
|
||||
end
|
||||
|
||||
defp parse_action_data(:skill, map) do
|
||||
skills =
|
||||
map
|
||||
|> Map.get(:value, Map.get(map, "value", []))
|
||||
|> parse_skill_list()
|
||||
|
||||
applicable_jobs =
|
||||
map
|
||||
|> Map.get(:applicable_jobs, Map.get(map, "applicable_jobs", []))
|
||||
|> parse_job_list()
|
||||
|
||||
{skills, applicable_jobs, 0}
|
||||
end
|
||||
|
||||
defp parse_action_data(:quest, map) do
|
||||
quests =
|
||||
map
|
||||
|> Map.get(:value, Map.get(map, "value", []))
|
||||
|> parse_quest_state_list()
|
||||
|
||||
{quests, [], 0}
|
||||
end
|
||||
|
||||
defp parse_action_data(:nextQuest, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:buffItemID, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(:infoNumber, map) do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
# Trait EXP actions
|
||||
defp parse_action_data(type, map)
|
||||
when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do
|
||||
int_store = Map.get(map, :value, Map.get(map, "value", Map.get(map, :int_store, 0)))
|
||||
{int_store, [], int_store}
|
||||
end
|
||||
|
||||
defp parse_action_data(_type, map) do
|
||||
{Map.get(map, :value, nil), [], 0}
|
||||
end
|
||||
|
||||
defp parse_job_list(nil), do: []
|
||||
defp parse_job_list(list) when is_list(list), do: list
|
||||
defp parse_job_list(map) when is_map(map), do: Map.values(map)
|
||||
|
||||
defp parse_item_list(items) when is_list(items) do
|
||||
Enum.map(items, fn item_data ->
|
||||
%QuestItem{
|
||||
item_id: Map.get(item_data, :id, Map.get(item_data, "id", Map.get(item_data, :item_id, 0))),
|
||||
count: Map.get(item_data, :count, Map.get(item_data, "count", 1)),
|
||||
period: Map.get(item_data, :period, Map.get(item_data, "period", 0)),
|
||||
gender: Map.get(item_data, :gender, Map.get(item_data, "gender", 2)),
|
||||
job: Map.get(item_data, :job, Map.get(item_data, "job", -1)),
|
||||
job_ex: Map.get(item_data, :jobEx, Map.get(item_data, :job_ex, Map.get(item_data, "jobEx", -1))),
|
||||
prop: Map.get(item_data, :prop, Map.get(item_data, "prop", -2))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_item_list(_), do: []
|
||||
|
||||
defp parse_skill_list(skills) when is_list(skills) do
|
||||
Enum.map(skills, fn skill_data ->
|
||||
%{
|
||||
skill_id: Map.get(skill_data, :id, Map.get(skill_data, "id", 0)),
|
||||
skill_level: Map.get(skill_data, :skill_level, Map.get(skill_data, "skillLevel", 0)),
|
||||
master_level: Map.get(skill_data, :master_level, Map.get(skill_data, "masterLevel", 0))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_skill_list(_), do: []
|
||||
|
||||
defp parse_quest_state_list(quests) when is_list(quests) do
|
||||
Enum.map(quests, fn quest_data ->
|
||||
{
|
||||
Map.get(quest_data, :id, Map.get(quest_data, "id", 0)),
|
||||
Map.get(quest_data, :state, Map.get(quest_data, "state", 0))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_quest_state_list(quests) when is_map(quests) do
|
||||
Enum.map(quests, fn {id, state} ->
|
||||
{String.to_integer(id), state}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_quest_state_list(_), do: []
|
||||
|
||||
# Start action implementations
|
||||
|
||||
defp do_run_start(:exp, %{int_store: exp} = _action, character) do
|
||||
# Apply EXP with quest rate multiplier
|
||||
# Full implementation would check GameConstants.getExpRate_Quest and trait bonuses
|
||||
apply_exp(character, exp)
|
||||
end
|
||||
|
||||
defp do_run_start(:money, %{int_store: meso} = _action, character) do
|
||||
current_meso = Map.get(character, :meso, 0)
|
||||
Map.put(character, :meso, current_meso + meso)
|
||||
end
|
||||
|
||||
defp do_run_start(:pop, %{int_store: fame} = _action, character) do
|
||||
current_fame = Map.get(character, :fame, 0)
|
||||
Map.put(character, :fame, current_fame + fame)
|
||||
end
|
||||
|
||||
defp do_run_start(:item, %{value: items} = action, character) do
|
||||
# Filter items by job/gender restrictions
|
||||
applicable_items =
|
||||
items
|
||||
|> Enum.filter(fn item -> can_get_item?(item, character) end)
|
||||
|> select_items_by_prop()
|
||||
|
||||
# Add items to inventory (simplified - full implementation needs inventory manipulation)
|
||||
Enum.reduce(applicable_items, character, fn item, char ->
|
||||
add_item_to_character(char, item)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_run_start(:sp, %{int_store: sp, applicable_jobs: jobs} = _action, character) do
|
||||
apply_skill_points(character, sp, jobs)
|
||||
end
|
||||
|
||||
defp do_run_start(:skill, %{value: skills, applicable_jobs: jobs} = _action, character) do
|
||||
apply_skills(character, skills, jobs)
|
||||
end
|
||||
|
||||
defp do_run_start(:quest, %{value: quest_states} = _action, character) do
|
||||
Enum.reduce(quest_states, character, fn {quest_id, state}, char ->
|
||||
update_quest_state(char, quest_id, state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_run_start(:nextQuest, %{int_store: next_quest_id} = _action, character) do
|
||||
# Queue next quest
|
||||
current_next = Map.get(character, :next_quest, nil)
|
||||
|
||||
if current_next == nil do
|
||||
Map.put(character, :next_quest, next_quest_id)
|
||||
else
|
||||
character
|
||||
end
|
||||
end
|
||||
|
||||
# Trait EXP start actions
|
||||
defp do_run_start(type, %{int_store: exp} = _action, character)
|
||||
when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do
|
||||
trait_name =
|
||||
case type do
|
||||
:charmEXP -> :charm
|
||||
:charismaEXP -> :charisma
|
||||
:craftEXP -> :craft
|
||||
:insightEXP -> :insight
|
||||
:senseEXP -> :sense
|
||||
:willEXP -> :will
|
||||
end
|
||||
|
||||
apply_trait_exp(character, trait_name, exp)
|
||||
end
|
||||
|
||||
defp do_run_start(_type, _action, character), do: character
|
||||
|
||||
# End action implementations (mostly same as start but without forfeiture check)
|
||||
|
||||
defp do_run_end(:exp, %{int_store: exp} = _action, character) do
|
||||
apply_exp(character, exp)
|
||||
end
|
||||
|
||||
defp do_run_end(:money, %{int_store: meso} = _action, character) do
|
||||
current_meso = Map.get(character, :meso, 0)
|
||||
Map.put(character, :meso, current_meso + meso)
|
||||
end
|
||||
|
||||
defp do_run_end(:pop, %{int_store: fame} = _action, character) do
|
||||
current_fame = Map.get(character, :fame, 0)
|
||||
Map.put(character, :fame, current_fame + fame)
|
||||
end
|
||||
|
||||
defp do_run_end(:item, %{value: items} = action, character) do
|
||||
applicable_items =
|
||||
items
|
||||
|> Enum.filter(fn item -> can_get_item?(item, character) end)
|
||||
|> select_items_by_prop()
|
||||
|
||||
Enum.reduce(applicable_items, character, fn item, char ->
|
||||
add_item_to_character(char, item)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_run_end(:sp, %{int_store: sp, applicable_jobs: jobs} = _action, character) do
|
||||
apply_skill_points(character, sp, jobs)
|
||||
end
|
||||
|
||||
defp do_run_end(:skill, %{value: skills, applicable_jobs: jobs} = _action, character) do
|
||||
apply_skills(character, skills, jobs)
|
||||
end
|
||||
|
||||
defp do_run_end(:quest, %{value: quest_states} = _action, character) do
|
||||
Enum.reduce(quest_states, character, fn {quest_id, state}, char ->
|
||||
update_quest_state(char, quest_id, state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_run_end(:nextQuest, %{int_store: next_quest_id} = _action, character) do
|
||||
current_next = Map.get(character, :next_quest, nil)
|
||||
|
||||
if current_next == nil do
|
||||
Map.put(character, :next_quest, next_quest_id)
|
||||
else
|
||||
character
|
||||
end
|
||||
end
|
||||
|
||||
defp do_run_end(:buffItemID, %{int_store: item_id} = _action, character) when item_id > 0 do
|
||||
# Apply item buff effect
|
||||
# Full implementation would get item effect from ItemInformationProvider
|
||||
character
|
||||
end
|
||||
|
||||
# Trait EXP end actions
|
||||
defp do_run_end(type, %{int_store: exp} = _action, character)
|
||||
when type in [:charmEXP, :charismaEXP, :craftEXP, :insightEXP, :senseEXP, :willEXP] do
|
||||
trait_name =
|
||||
case type do
|
||||
:charmEXP -> :charm
|
||||
:charismaEXP -> :charisma
|
||||
:craftEXP -> :craft
|
||||
:insightEXP -> :insight
|
||||
:senseEXP -> :sense
|
||||
:willEXP -> :will
|
||||
end
|
||||
|
||||
apply_trait_exp(character, trait_name, exp)
|
||||
end
|
||||
|
||||
defp do_run_end(_type, _action, character), do: character
|
||||
|
||||
# Check end implementations
|
||||
|
||||
defp do_check_end(:item, %{value: items} = action, character) do
|
||||
# Check if character has inventory space for items
|
||||
applicable_items =
|
||||
items
|
||||
|> Enum.filter(fn item -> can_get_item?(item, character) end)
|
||||
|> select_items_by_prop()
|
||||
|
||||
# Count items by inventory type
|
||||
needed_slots =
|
||||
Enum.reduce(applicable_items, %{equip: 0, use: 0, setup: 0, etc: 0, cash: 0}, fn item, acc ->
|
||||
inv_type = get_inventory_type(item.item_id)
|
||||
Map.update(acc, inv_type, 1, &(&1 + 1))
|
||||
end)
|
||||
|
||||
# Check available space (simplified)
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
|
||||
Enum.all?(needed_slots, fn {type, count} ->
|
||||
current_items = Map.get(inventory, type, [])
|
||||
max_slots = get_max_slots(type)
|
||||
length(current_items) + count <= max_slots
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check_end(:money, %{int_store: meso} = _action, character) do
|
||||
current_meso = Map.get(character, :meso, 0)
|
||||
|
||||
cond do
|
||||
meso > 0 and current_meso + meso > 2_147_483_647 ->
|
||||
# Would overflow
|
||||
false
|
||||
|
||||
meso < 0 and current_meso < abs(meso) ->
|
||||
# Not enough meso
|
||||
false
|
||||
|
||||
true ->
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check_end(_type, _action, _character), do: true
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp select_items_by_prop(items) do
|
||||
# Handle probability-based item selection
|
||||
# Items with prop > 0 are selected randomly
|
||||
# Items with prop == -1 are selection-based (user chooses)
|
||||
# Items with prop == -2 are always given
|
||||
|
||||
{random_items, other_items} =
|
||||
Enum.split_with(items, fn item -> item.prop > 0 end)
|
||||
|
||||
if length(random_items) > 0 do
|
||||
# Create weighted pool
|
||||
pool =
|
||||
Enum.flat_map(random_items, fn item ->
|
||||
List.duplicate(item, item.prop)
|
||||
end)
|
||||
|
||||
selected = Enum.random(pool)
|
||||
[selected | other_items]
|
||||
else
|
||||
other_items
|
||||
end
|
||||
end
|
||||
|
||||
defp add_item_to_character(character, %QuestItem{} = item) do
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
inv_type = get_inventory_type(item.item_id)
|
||||
|
||||
new_item = %{
|
||||
item_id: item.item_id,
|
||||
quantity: item.count,
|
||||
position: find_next_slot(inventory, inv_type),
|
||||
expiration: if(item.period > 0, do: System.system_time(:second) + item.period * 60, else: -1)
|
||||
}
|
||||
|
||||
updated_inventory =
|
||||
Map.update(inventory, inv_type, [new_item], fn items ->
|
||||
[new_item | items]
|
||||
end)
|
||||
|
||||
Map.put(character, :inventory, updated_inventory)
|
||||
end
|
||||
|
||||
defp get_inventory_type(item_id) do
|
||||
prefix = div(item_id, 1_000_000)
|
||||
|
||||
case prefix do
|
||||
1 -> :equip
|
||||
2 -> :use
|
||||
3 -> :setup
|
||||
4 -> :etc
|
||||
5 -> :cash
|
||||
_ -> :etc
|
||||
end
|
||||
end
|
||||
|
||||
defp get_max_slots(type) do
|
||||
case type do
|
||||
:equip -> 24
|
||||
:use -> 80
|
||||
:setup -> 80
|
||||
:etc -> 80
|
||||
:cash -> 40
|
||||
_ -> 80
|
||||
end
|
||||
end
|
||||
|
||||
defp find_next_slot(inventory, type) do
|
||||
items = Map.get(inventory, type, [])
|
||||
positions = Enum.map(items, & &1.position)
|
||||
|
||||
Enum.find(1..100, fn slot ->
|
||||
slot not in positions
|
||||
end) || 0
|
||||
end
|
||||
|
||||
defp apply_exp(character, base_exp) do
|
||||
level = Map.get(character, :level, 1)
|
||||
|
||||
# Apply quest EXP rate
|
||||
exp_rate = 1.0 # Would get from GameConstants
|
||||
|
||||
# Apply trait bonus (Sense trait gives quest EXP bonus)
|
||||
traits = Map.get(character, :traits, %{})
|
||||
sense_level = Map.get(traits, :sense, 0)
|
||||
trait_bonus = 1.0 + (sense_level * 3 / 1000)
|
||||
|
||||
final_exp = trunc(base_exp * exp_rate * trait_bonus)
|
||||
|
||||
# Add EXP to character
|
||||
current_exp = Map.get(character, :exp, 0)
|
||||
Map.put(character, :exp, current_exp + final_exp)
|
||||
end
|
||||
|
||||
defp apply_skill_points(character, sp, jobs) do
|
||||
character_job = Map.get(character, :job, 0)
|
||||
|
||||
# Find most applicable job
|
||||
applicable_job =
|
||||
jobs
|
||||
|> Enum.filter(fn job -> character_job >= job end)
|
||||
|> Enum.max(fn -> 0 end)
|
||||
|
||||
sp_type =
|
||||
if applicable_job == 0 do
|
||||
# Beginner SP
|
||||
0
|
||||
else
|
||||
# Get skill book based on job
|
||||
get_skill_book(applicable_job)
|
||||
end
|
||||
|
||||
current_sp = Map.get(character, :sp, [])
|
||||
updated_sp = List.replace_at(current_sp, sp_type, (Enum.at(current_sp, sp_type, 0) || 0) + sp)
|
||||
|
||||
Map.put(character, :sp, updated_sp)
|
||||
end
|
||||
|
||||
defp get_skill_book(job) do
|
||||
# Get skill book index for job
|
||||
cond do
|
||||
job >= 1000 and job < 2000 -> 1
|
||||
job >= 2000 and job < 3000 -> 2
|
||||
job >= 3000 and job < 4000 -> 3
|
||||
job >= 4000 and job < 5000 -> 4
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_skills(character, skills, applicable_jobs) do
|
||||
character_job = Map.get(character, :job, 0)
|
||||
|
||||
# Check if any job matches
|
||||
job_matches = Enum.any?(applicable_jobs, fn job -> character_job == job end)
|
||||
|
||||
if job_matches or applicable_jobs == [] do
|
||||
current_skills = Map.get(character, :skills, %{})
|
||||
current_master_levels = Map.get(character, :skill_master_levels, %{})
|
||||
|
||||
Enum.reduce(skills, character, fn skill, char ->
|
||||
skill_id = skill.skill_id
|
||||
skill_level = skill.skill_level
|
||||
master_level = skill.master_level
|
||||
|
||||
# Get current levels
|
||||
current_level = Map.get(current_skills, skill_id, 0)
|
||||
current_master = Map.get(current_master_levels, skill_id, 0)
|
||||
|
||||
# Update with max of current/new
|
||||
new_skills = Map.put(current_skills, skill_id, max(skill_level, current_level))
|
||||
new_masters = Map.put(current_master_levels, skill_id, max(master_level, current_master))
|
||||
|
||||
char
|
||||
|> Map.put(:skills, new_skills)
|
||||
|> Map.put(:skill_master_levels, new_masters)
|
||||
end)
|
||||
else
|
||||
character
|
||||
end
|
||||
end
|
||||
|
||||
defp update_quest_state(character, quest_id, state) do
|
||||
quest_progress = Map.get(character, :quest_progress, %{})
|
||||
updated_progress = Map.put(quest_progress, quest_id, state)
|
||||
Map.put(character, :quest_progress, updated_progress)
|
||||
end
|
||||
|
||||
defp apply_trait_exp(character, trait_name, exp) do
|
||||
traits = Map.get(character, :traits, %{})
|
||||
current_exp = Map.get(traits, trait_name, 0)
|
||||
updated_traits = Map.put(traits, trait_name, current_exp + exp)
|
||||
Map.put(character, :traits, updated_traits)
|
||||
end
|
||||
end
|
||||
459
lib/odinsea/game/quest_progress.ex
Normal file
459
lib/odinsea/game/quest_progress.ex
Normal file
@@ -0,0 +1,459 @@
|
||||
defmodule Odinsea.Game.QuestProgress do
|
||||
@moduledoc """
|
||||
Player Quest Progress tracking module.
|
||||
|
||||
Tracks individual player's quest states:
|
||||
- Quest status (not started, in progress, completed)
|
||||
- Mob kill counts for active quests
|
||||
- Custom quest data (for scripted quests)
|
||||
- Forfeiture count
|
||||
- Completion time
|
||||
- NPC ID (for quest tracking)
|
||||
|
||||
## Quest Status
|
||||
|
||||
- `0` - Not started
|
||||
- `1` - In progress
|
||||
- `2` - Completed
|
||||
|
||||
## Progress Structure
|
||||
|
||||
Each quest progress entry contains:
|
||||
- Quest ID
|
||||
- Status (0/1/2)
|
||||
- Mob kills (map of mob_id => count)
|
||||
- Forfeited count
|
||||
- Completion time (timestamp)
|
||||
- Custom data (string for scripted quests)
|
||||
- NPC ID (related NPC for the quest)
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Quest
|
||||
|
||||
defmodule ProgressEntry do
|
||||
@moduledoc "Individual quest progress entry"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
quest_id: integer(),
|
||||
status: integer(),
|
||||
mob_kills: %{integer() => integer()},
|
||||
forfeited: integer(),
|
||||
completion_time: integer() | nil,
|
||||
custom_data: String.t() | nil,
|
||||
npc_id: integer() | nil
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:quest_id,
|
||||
:status,
|
||||
mob_kills: %{},
|
||||
forfeited: 0,
|
||||
completion_time: nil,
|
||||
custom_data: nil,
|
||||
npc_id: nil
|
||||
]
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
character_id: integer(),
|
||||
quests: %{integer() => ProgressEntry.t()}
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:character_id,
|
||||
quests: %{}
|
||||
]
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Creates a new empty quest progress for a character"
|
||||
@spec new(integer()) :: t()
|
||||
def new(character_id) do
|
||||
%__MODULE__{
|
||||
character_id: character_id,
|
||||
quests: %{}
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Gets a quest's progress entry"
|
||||
@spec get_quest(t(), integer()) :: ProgressEntry.t() | nil
|
||||
def get_quest(%__MODULE__{} = progress, quest_id) do
|
||||
Map.get(progress.quests, quest_id)
|
||||
end
|
||||
|
||||
@doc "Gets the status of a quest"
|
||||
@spec get_status(t(), integer()) :: integer()
|
||||
def get_status(%__MODULE__{} = progress, quest_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil -> 0
|
||||
entry -> entry.status
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if a quest is in progress"
|
||||
@spec in_progress?(t(), integer()) :: boolean()
|
||||
def in_progress?(%__MODULE__{} = progress, quest_id) do
|
||||
get_status(progress, quest_id) == 1
|
||||
end
|
||||
|
||||
@doc "Checks if a quest is completed"
|
||||
@spec completed?(t(), integer()) :: boolean()
|
||||
def completed?(%__MODULE__{} = progress, quest_id) do
|
||||
get_status(progress, quest_id) == 2
|
||||
end
|
||||
|
||||
@doc "Checks if a quest can be started"
|
||||
@spec can_start?(t(), integer()) :: boolean()
|
||||
def can_start?(%__MODULE__{} = progress, quest_id) do
|
||||
status = get_status(progress, quest_id)
|
||||
|
||||
case Quest.get_quest(quest_id) do
|
||||
nil ->
|
||||
# Unknown quest, can't start
|
||||
false
|
||||
|
||||
quest ->
|
||||
# Can start if:
|
||||
# 1. Status is 0 (not started), OR
|
||||
# 2. Status is 2 (completed) AND quest is repeatable
|
||||
status == 0 || (status == 2 && quest.repeatable)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Starts a quest"
|
||||
@spec start_quest(t(), integer(), integer() | nil) :: t()
|
||||
def start_quest(%__MODULE__{} = progress, quest_id, npc_id \\ nil) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
entry = %ProgressEntry{
|
||||
quest_id: quest_id,
|
||||
status: 1,
|
||||
npc_id: npc_id,
|
||||
mob_kills: %{},
|
||||
completion_time: now
|
||||
}
|
||||
|
||||
update_quest_entry(progress, entry)
|
||||
end
|
||||
|
||||
@doc "Completes a quest"
|
||||
@spec complete_quest(t(), integer(), integer() | nil) :: t()
|
||||
def complete_quest(%__MODULE__{} = progress, quest_id, npc_id \\ nil) do
|
||||
now = System.system_time(:second)
|
||||
|
||||
entry =
|
||||
case get_quest(progress, quest_id) do
|
||||
nil ->
|
||||
%ProgressEntry{
|
||||
quest_id: quest_id,
|
||||
status: 2,
|
||||
npc_id: npc_id,
|
||||
completion_time: now
|
||||
}
|
||||
|
||||
existing ->
|
||||
%ProgressEntry{
|
||||
existing
|
||||
| status: 2,
|
||||
npc_id: npc_id,
|
||||
completion_time: now
|
||||
}
|
||||
end
|
||||
|
||||
update_quest_entry(progress, entry)
|
||||
end
|
||||
|
||||
@doc "Forfeits a quest (abandons it)"
|
||||
@spec forfeit_quest(t(), integer()) :: t()
|
||||
def forfeit_quest(%__MODULE__{} = progress, quest_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil ->
|
||||
# Quest not started, nothing to forfeit
|
||||
progress
|
||||
|
||||
entry when entry.status == 1 ->
|
||||
# Quest is in progress, forfeit it
|
||||
forfeited = entry.forfeited + 1
|
||||
|
||||
updated_entry = %ProgressEntry{
|
||||
quest_id: quest_id,
|
||||
status: 0,
|
||||
forfeited: forfeited,
|
||||
completion_time: entry.completion_time,
|
||||
custom_data: nil,
|
||||
mob_kills: %{}
|
||||
}
|
||||
|
||||
update_quest_entry(progress, updated_entry)
|
||||
|
||||
_entry ->
|
||||
# Quest not in progress, can't forfeit
|
||||
progress
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Resets a quest to not started state"
|
||||
@spec reset_quest(t(), integer()) :: t()
|
||||
def reset_quest(%__MODULE__{} = progress, quest_id) do
|
||||
%{progress | quests: Map.delete(progress.quests, quest_id)}
|
||||
end
|
||||
|
||||
@doc "Records a mob kill for an active quest"
|
||||
@spec record_mob_kill(t(), integer(), integer()) :: t()
|
||||
def record_mob_kill(%__MODULE__{} = progress, quest_id, mob_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil ->
|
||||
# Quest not started
|
||||
progress
|
||||
|
||||
entry when entry.status != 1 ->
|
||||
# Quest not in progress
|
||||
progress
|
||||
|
||||
entry ->
|
||||
# Check if this mob is relevant to the quest
|
||||
case Quest.get_relevant_mobs(quest_id) do
|
||||
%{^mob_id => required_count} ->
|
||||
current_count = Map.get(entry.mob_kills, mob_id, 0)
|
||||
|
||||
# Only increment if not yet completed
|
||||
new_count = min(current_count + 1, required_count)
|
||||
|
||||
updated_mob_kills = Map.put(entry.mob_kills, mob_id, new_count)
|
||||
updated_entry = %ProgressEntry{entry | mob_kills: updated_mob_kills}
|
||||
|
||||
update_quest_entry(progress, updated_entry)
|
||||
|
||||
_ ->
|
||||
# Mob not relevant to this quest
|
||||
progress
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets mob kill count for a quest"
|
||||
@spec get_mob_kills(t(), integer(), integer()) :: integer()
|
||||
def get_mob_kills(%__MODULE__{} = progress, quest_id, mob_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil -> 0
|
||||
entry -> Map.get(entry.mob_kills, mob_id, 0)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Sets custom data for a quest"
|
||||
@spec set_custom_data(t(), integer(), String.t()) :: t()
|
||||
def set_custom_data(%__MODULE__{} = progress, quest_id, data) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil ->
|
||||
# Quest not started, create entry with custom data
|
||||
entry = %ProgressEntry{
|
||||
quest_id: quest_id,
|
||||
status: 1,
|
||||
custom_data: data
|
||||
}
|
||||
|
||||
update_quest_entry(progress, entry)
|
||||
|
||||
entry ->
|
||||
updated_entry = %ProgressEntry{entry | custom_data: data}
|
||||
update_quest_entry(progress, updated_entry)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets custom data for a quest"
|
||||
@spec get_custom_data(t(), integer()) :: String.t() | nil
|
||||
def get_custom_data(%__MODULE__{} = progress, quest_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil -> nil
|
||||
entry -> entry.custom_data
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Sets the NPC ID for a quest"
|
||||
@spec set_npc(t(), integer(), integer()) :: t()
|
||||
def set_npc(%__MODULE__{} = progress, quest_id, npc_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil ->
|
||||
entry = %ProgressEntry{
|
||||
quest_id: quest_id,
|
||||
status: 0,
|
||||
npc_id: npc_id
|
||||
}
|
||||
|
||||
update_quest_entry(progress, entry)
|
||||
|
||||
entry ->
|
||||
updated_entry = %ProgressEntry{entry | npc_id: npc_id}
|
||||
update_quest_entry(progress, updated_entry)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets the NPC ID for a quest"
|
||||
@spec get_npc(t(), integer()) :: integer() | nil
|
||||
def get_npc(%__MODULE__{} = progress, quest_id) do
|
||||
case get_quest(progress, quest_id) do
|
||||
nil -> nil
|
||||
entry -> entry.npc_id
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets all active (in-progress) quests"
|
||||
@spec get_active_quests(t()) :: [ProgressEntry.t()]
|
||||
def get_active_quests(%__MODULE__{} = progress) do
|
||||
progress.quests
|
||||
|> Map.values()
|
||||
|> Enum.filter(fn entry -> entry.status == 1 end)
|
||||
end
|
||||
|
||||
@doc "Gets all completed quests"
|
||||
@spec get_completed_quests(t()) :: [ProgressEntry.t()]
|
||||
def get_completed_quests(%__MODULE__{} = progress) do
|
||||
progress.quests
|
||||
|> Map.values()
|
||||
|> Enum.filter(fn entry -> entry.status == 2 end)
|
||||
end
|
||||
|
||||
@doc "Gets count of completed quests"
|
||||
@spec get_completed_count(t()) :: integer()
|
||||
def get_completed_count(%__MODULE__{} = progress) do
|
||||
progress.quests
|
||||
|> Map.values()
|
||||
|> Enum.count(fn entry -> entry.status == 2 end)
|
||||
end
|
||||
|
||||
@doc "Checks if a quest can be repeated (interval passed)"
|
||||
@spec can_repeat?(t(), integer()) :: boolean()
|
||||
def can_repeat?(%__MODULE__{} = progress, quest_id) do
|
||||
case Quest.get_quest(quest_id) do
|
||||
nil -> false
|
||||
quest ->
|
||||
if not quest.repeatable do
|
||||
false
|
||||
else
|
||||
case get_quest(progress, quest_id) do
|
||||
nil -> true
|
||||
entry ->
|
||||
case entry.completion_time do
|
||||
nil -> true
|
||||
last_completion ->
|
||||
# Check interval requirement
|
||||
interval_req =
|
||||
Enum.find(quest.complete_requirements, fn req ->
|
||||
req.type == :interval
|
||||
end)
|
||||
|
||||
interval_seconds =
|
||||
case interval_req do
|
||||
nil -> 0
|
||||
req -> req.data * 60 # Convert minutes to seconds
|
||||
end
|
||||
|
||||
now = System.system_time(:second)
|
||||
(now - last_completion) >= interval_seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Converts progress to a map for database storage"
|
||||
@spec to_map(t()) :: map()
|
||||
def to_map(%__MODULE__{} = progress) do
|
||||
%{
|
||||
character_id: progress.character_id,
|
||||
quests:
|
||||
Enum.into(progress.quests, %{}, fn {quest_id, entry} ->
|
||||
{quest_id, entry_to_map(entry)}
|
||||
end)
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Creates progress from a map (database deserialization)"
|
||||
@spec from_map(map()) :: t()
|
||||
def from_map(map) do
|
||||
character_id = Map.get(map, :character_id, Map.get(map, "character_id", 0))
|
||||
|
||||
quests =
|
||||
map
|
||||
|> Map.get(:quests, Map.get(map, "quests", %{}))
|
||||
|> Enum.into(%{}, fn {quest_id_str, entry_data} ->
|
||||
quest_id =
|
||||
if is_binary(quest_id_str) do
|
||||
String.to_integer(quest_id_str)
|
||||
else
|
||||
quest_id_str
|
||||
end
|
||||
|
||||
{quest_id, entry_from_map(entry_data)}
|
||||
end)
|
||||
|
||||
%__MODULE__{
|
||||
character_id: character_id,
|
||||
quests: quests
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Merges progress from database with current state"
|
||||
@spec merge(t(), t()) :: t()
|
||||
def merge(%__MODULE__{} = current, %__MODULE__{} = loaded) do
|
||||
# Prefer loaded data for completed quests
|
||||
# Keep current data for in-progress quests if newer
|
||||
|
||||
merged_quests =
|
||||
Map.merge(loaded.quests, current.quests, fn _quest_id, loaded_entry, current_entry ->
|
||||
cond do
|
||||
loaded_entry.status == 2 and current_entry.status != 2 ->
|
||||
# Keep completed status from loaded
|
||||
loaded_entry
|
||||
|
||||
current_entry.status == 2 and loaded_entry.status != 2 ->
|
||||
# Newly completed
|
||||
current_entry
|
||||
|
||||
current_entry.completion_time && loaded_entry.completion_time ->
|
||||
if current_entry.completion_time > loaded_entry.completion_time do
|
||||
current_entry
|
||||
else
|
||||
loaded_entry
|
||||
end
|
||||
|
||||
true ->
|
||||
# Default to current
|
||||
current_entry
|
||||
end
|
||||
end)
|
||||
|
||||
%__MODULE__{current | quests: merged_quests}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp update_quest_entry(%__MODULE__{} = progress, %ProgressEntry{} = entry) do
|
||||
updated_quests = Map.put(progress.quests, entry.quest_id, entry)
|
||||
%{progress | quests: updated_quests}
|
||||
end
|
||||
|
||||
defp entry_to_map(%ProgressEntry{} = entry) do
|
||||
%{
|
||||
quest_id: entry.quest_id,
|
||||
status: entry.status,
|
||||
mob_kills: entry.mob_kills,
|
||||
forfeited: entry.forfeited,
|
||||
completion_time: entry.completion_time,
|
||||
custom_data: entry.custom_data,
|
||||
npc_id: entry.npc_id
|
||||
}
|
||||
end
|
||||
|
||||
defp entry_from_map(map) do
|
||||
%ProgressEntry{
|
||||
quest_id: Map.get(map, :quest_id, Map.get(map, "quest_id", 0)),
|
||||
status: Map.get(map, :status, Map.get(map, "status", 0)),
|
||||
mob_kills: Map.get(map, :mob_kills, Map.get(map, "mob_kills", %{})),
|
||||
forfeited: Map.get(map, :forfeited, Map.get(map, "forfeited", 0)),
|
||||
completion_time: Map.get(map, :completion_time, Map.get(map, "completion_time", nil)),
|
||||
custom_data: Map.get(map, :custom_data, Map.get(map, "custom_data", nil)),
|
||||
npc_id: Map.get(map, :npc_id, Map.get(map, "npc_id", nil))
|
||||
}
|
||||
end
|
||||
end
|
||||
478
lib/odinsea/game/quest_requirement.ex
Normal file
478
lib/odinsea/game/quest_requirement.ex
Normal file
@@ -0,0 +1,478 @@
|
||||
defmodule Odinsea.Game.QuestRequirement do
|
||||
@moduledoc """
|
||||
Quest Requirement module - defines conditions for quest start/completion.
|
||||
|
||||
Requirements are checked when:
|
||||
- Starting a quest (start_requirements)
|
||||
- Completing a quest (complete_requirements)
|
||||
|
||||
## Requirement Types
|
||||
|
||||
- `:job` - Required job class
|
||||
- `:item` - Required items in inventory
|
||||
- `:quest` - Required quest completion status
|
||||
- `:lvmin` - Minimum level
|
||||
- `:lvmax` - Maximum level
|
||||
- `:mob` - Required mob kills
|
||||
- `:npc` - NPC to talk to
|
||||
- `:fieldEnter` - Enter specific map(s)
|
||||
- `:pop` - Minimum fame
|
||||
- `:interval` - Time interval for repeatable quests
|
||||
- `:skill` - Required skill level
|
||||
- `:pet` - Required pet
|
||||
- `:mbmin` - Monster book minimum cards
|
||||
- `:mbcard` - Specific monster book card level
|
||||
- `:questComplete` - Minimum number of completed quests
|
||||
- `:subJobFlags` - Sub-job flags (e.g., Dual Blade)
|
||||
- `:pettamenessmin` - Minimum pet closeness
|
||||
- `:partyQuest_S` - S-rank party quest completions
|
||||
- `:charmMin`, `:senseMin`, `:craftMin`, `:willMin`, `:charismaMin`, `:insightMin` - Trait minimums
|
||||
- `:dayByDay` - Daily quest
|
||||
- `:normalAutoStart` - Auto-start quest
|
||||
- `:startscript`, `:endscript` - Custom scripts
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
type: atom(),
|
||||
data: any()
|
||||
}
|
||||
|
||||
defstruct [:type, :data]
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Creates a new quest requirement"
|
||||
@spec new(atom(), any()) :: t()
|
||||
def new(type, data) do
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
data: data
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Builds a requirement from a map (JSON deserialization)"
|
||||
@spec from_map(map()) :: t()
|
||||
def from_map(map) do
|
||||
type = parse_type(Map.get(map, :type, Map.get(map, "type", "undefined")))
|
||||
data = parse_data(type, Map.get(map, :data, Map.get(map, "data", nil)))
|
||||
|
||||
%__MODULE__{
|
||||
type: type,
|
||||
data: data
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Checks if a character meets this requirement"
|
||||
@spec check(t(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def check(%__MODULE__{} = req, character) do
|
||||
do_check(req.type, req.data, character)
|
||||
end
|
||||
|
||||
@doc "Parses a WZ requirement name into an atom"
|
||||
@spec parse_type(String.t() | atom()) :: atom()
|
||||
def parse_type(type) when is_atom(type), do: type
|
||||
|
||||
def parse_type(type_str) when is_binary(type_str) do
|
||||
case String.downcase(type_str) do
|
||||
"job" -> :job
|
||||
"item" -> :item
|
||||
"quest" -> :quest
|
||||
"lvmin" -> :lvmin
|
||||
"lvmax" -> :lvmax
|
||||
"end" -> :end
|
||||
"mob" -> :mob
|
||||
"npc" -> :npc
|
||||
"fieldenter" -> :fieldEnter
|
||||
"interval" -> :interval
|
||||
"startscript" -> :startscript
|
||||
"endscript" -> :endscript
|
||||
"pet" -> :pet
|
||||
"pettamenessmin" -> :pettamenessmin
|
||||
"mbmin" -> :mbmin
|
||||
"questcomplete" -> :questComplete
|
||||
"pop" -> :pop
|
||||
"skill" -> :skill
|
||||
"mbcard" -> :mbcard
|
||||
"subjobflags" -> :subJobFlags
|
||||
"daybyday" -> :dayByDay
|
||||
"normalautostart" -> :normalAutoStart
|
||||
"partyquest_s" -> :partyQuest_S
|
||||
"charmmin" -> :charmMin
|
||||
"sensemin" -> :senseMin
|
||||
"craftmin" -> :craftMin
|
||||
"willmin" -> :willMin
|
||||
"charismamin" -> :charismaMin
|
||||
"insightmin" -> :insightMin
|
||||
_ -> :undefined
|
||||
end
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp parse_data(:job, data) when is_list(data), do: data
|
||||
defp parse_data(:job, data) when is_integer(data), do: [data]
|
||||
defp parse_data(:job, data) when is_binary(data), do: [String.to_integer(data)]
|
||||
|
||||
defp parse_data(:item, data) when is_map(data), do: data
|
||||
defp parse_data(:item, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn item, acc ->
|
||||
item_id = Map.get(item, :id, Map.get(item, "id", 0))
|
||||
count = Map.get(item, :count, Map.get(item, "count", 1))
|
||||
Map.put(acc, item_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:quest, data) when is_map(data), do: data
|
||||
defp parse_data(:quest, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn quest, acc ->
|
||||
quest_id = Map.get(quest, :id, Map.get(quest, "id", 0))
|
||||
state = Map.get(quest, :state, Map.get(quest, "state", 0))
|
||||
Map.put(acc, quest_id, state)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:mob, data) when is_map(data), do: data
|
||||
defp parse_data(:mob, data) when is_list(data) do
|
||||
Enum.reduce(data, %{}, fn mob, acc ->
|
||||
mob_id = Map.get(mob, :id, Map.get(mob, "id", 0))
|
||||
count = Map.get(mob, :count, Map.get(mob, "count", 1))
|
||||
Map.put(acc, mob_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:lvmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:lvmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:lvmax, data) when is_integer(data), do: data
|
||||
defp parse_data(:lvmax, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:npc, data) when is_integer(data), do: data
|
||||
defp parse_data(:npc, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:pop, data) when is_integer(data), do: data
|
||||
defp parse_data(:pop, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:interval, data) when is_integer(data), do: data
|
||||
defp parse_data(:interval, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:fieldEnter, data) when is_list(data), do: data
|
||||
defp parse_data(:fieldEnter, data) when is_integer(data), do: [data]
|
||||
defp parse_data(:fieldEnter, data) when is_binary(data) do
|
||||
case Integer.parse(data) do
|
||||
{int, _} -> [int]
|
||||
:error -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_data(:questComplete, data) when is_integer(data), do: data
|
||||
defp parse_data(:questComplete, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:mbmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:mbmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:pettamenessmin, data) when is_integer(data), do: data
|
||||
defp parse_data(:pettamenessmin, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:subJobFlags, data) when is_integer(data), do: data
|
||||
defp parse_data(:subJobFlags, data) when is_binary(data), do: String.to_integer(data)
|
||||
|
||||
defp parse_data(:skill, data) when is_list(data) do
|
||||
Enum.map(data, fn skill ->
|
||||
id = Map.get(skill, :id, Map.get(skill, "id", 0))
|
||||
acquire = Map.get(skill, :acquire, Map.get(skill, "acquire", 0))
|
||||
{id, acquire > 0}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:mbcard, data) when is_list(data) do
|
||||
Enum.map(data, fn card ->
|
||||
id = Map.get(card, :id, Map.get(card, "id", 0))
|
||||
min = Map.get(card, :min, Map.get(card, "min", 0))
|
||||
{id, min}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_data(:pet, data) when is_list(data), do: data
|
||||
defp parse_data(:pet, data) when is_integer(data), do: [data]
|
||||
|
||||
defp parse_data(:startscript, data), do: to_string(data)
|
||||
defp parse_data(:endscript, data), do: to_string(data)
|
||||
defp parse_data(:end, data), do: to_string(data)
|
||||
|
||||
# Trait minimums
|
||||
defp parse_data(type, data) when type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do
|
||||
if is_binary(data), do: String.to_integer(data), else: data
|
||||
end
|
||||
|
||||
defp parse_data(_type, data), do: data
|
||||
|
||||
# Requirement checking implementations
|
||||
|
||||
defp do_check(:job, required_jobs, character) do
|
||||
# Check if character's job is in the list of acceptable jobs
|
||||
character_job = Map.get(character, :job, 0)
|
||||
character_job in required_jobs || Map.get(character, :gm, false)
|
||||
end
|
||||
|
||||
defp do_check(:item, required_items, character) do
|
||||
# Check if character has required items
|
||||
# This is a simplified check - full implementation needs inventory lookup
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
|
||||
Enum.all?(required_items, fn {item_id, count} ->
|
||||
has_item_count(inventory, item_id, count)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:quest, required_quests, character) do
|
||||
# Check quest completion status
|
||||
quest_progress = Map.get(character, :quest_progress, %{})
|
||||
|
||||
Enum.all?(required_quests, fn {quest_id, required_state} ->
|
||||
actual_state = Map.get(quest_progress, quest_id, 0)
|
||||
# State: 0 = not started, 1 = in progress, 2 = completed
|
||||
actual_state == required_state
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:lvmin, min_level, character) do
|
||||
Map.get(character, :level, 1) >= min_level
|
||||
end
|
||||
|
||||
defp do_check(:lvmax, max_level, character) do
|
||||
Map.get(character, :level, 1) <= max_level
|
||||
end
|
||||
|
||||
defp do_check(:mob, required_mobs, character) do
|
||||
# Check mob kill counts from quest progress
|
||||
mob_kills = Map.get(character, :quest_mob_kills, %{})
|
||||
|
||||
Enum.all?(required_mobs, fn {mob_id, count} ->
|
||||
Map.get(mob_kills, mob_id, 0) >= count
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:npc, npc_id, character) do
|
||||
# NPC check is usually done at runtime with the actual NPC ID
|
||||
# This is a placeholder that returns true
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:npc, npc_id, character, talking_npc_id) do
|
||||
npc_id == talking_npc_id
|
||||
end
|
||||
|
||||
defp do_check(:fieldEnter, maps, character) do
|
||||
current_map = Map.get(character, :map_id, 0)
|
||||
current_map in maps
|
||||
end
|
||||
|
||||
defp do_check(:pop, min_fame, character) do
|
||||
Map.get(character, :fame, 0) >= min_fame
|
||||
end
|
||||
|
||||
defp do_check(:interval, interval_minutes, character) do
|
||||
# Check if enough time has passed for repeatable quest
|
||||
last_completion = Map.get(character, :last_quest_completion, %{})
|
||||
quest_id = Map.get(character, :checking_quest_id, 0)
|
||||
last_time = Map.get(last_completion, quest_id, 0)
|
||||
|
||||
if last_time == 0 do
|
||||
true
|
||||
else
|
||||
current_time = System.system_time(:second)
|
||||
(current_time - last_time) >= interval_minutes * 60
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check(:questComplete, min_completed, character) do
|
||||
completed_count =
|
||||
character
|
||||
|> Map.get(:quest_progress, %{})
|
||||
|> Enum.count(fn {_id, state} -> state == 2 end)
|
||||
|
||||
completed_count >= min_completed
|
||||
end
|
||||
|
||||
defp do_check(:mbmin, min_cards, character) do
|
||||
# Monster book card count check
|
||||
monster_book = Map.get(character, :monster_book, %{})
|
||||
card_count = map_size(monster_book)
|
||||
card_count >= min_cards
|
||||
end
|
||||
|
||||
defp do_check(:skill, required_skills, character) do
|
||||
skills = Map.get(character, :skills, %{})
|
||||
|
||||
Enum.all?(required_skills, fn {skill_id, should_have} ->
|
||||
skill_level = Map.get(skills, skill_id, 0)
|
||||
master_level = Map.get(character, :skill_master_levels, %{}) |> Map.get(skill_id, 0)
|
||||
|
||||
has_skill = skill_level > 0 || master_level > 0
|
||||
|
||||
if should_have do
|
||||
has_skill
|
||||
else
|
||||
not has_skill
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:pet, pet_ids, character) do
|
||||
pets = Map.get(character, :pets, [])
|
||||
|
||||
Enum.any?(pet_ids, fn pet_id ->
|
||||
Enum.any?(pets, fn pet ->
|
||||
Map.get(pet, :item_id) == pet_id && Map.get(pet, :summoned, false)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:pettamenessmin, min_closeness, character) do
|
||||
pets = Map.get(character, :pets, [])
|
||||
|
||||
Enum.any?(pets, fn pet ->
|
||||
Map.get(pet, :summoned, false) && Map.get(pet, :closeness, 0) >= min_closeness
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:subJobFlags, flags, character) do
|
||||
subcategory = Map.get(character, :subcategory, 0)
|
||||
# Sub-job flags check (used for Dual Blade, etc.)
|
||||
subcategory == div(flags, 2)
|
||||
end
|
||||
|
||||
defp do_check(:mbcard, required_cards, character) do
|
||||
monster_book = Map.get(character, :monster_book, %{})
|
||||
|
||||
Enum.all?(required_cards, fn {card_id, min_level} ->
|
||||
Map.get(monster_book, card_id, 0) >= min_level
|
||||
end)
|
||||
end
|
||||
|
||||
defp do_check(:dayByDay, _data, _character) do
|
||||
# Daily quest - handled separately
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:normalAutoStart, _data, _character) do
|
||||
# Auto-start flag
|
||||
true
|
||||
end
|
||||
|
||||
defp do_check(:partyQuest_S, _data, character) do
|
||||
# S-rank party quest check - simplified
|
||||
# Real implementation would check character's PQ history
|
||||
true
|
||||
end
|
||||
|
||||
# Trait minimum checks
|
||||
defp do_check(trait_type, min_level, character) when trait_type in [:charmMin, :senseMin, :craftMin, :willMin, :charismaMin, :insightMin] do
|
||||
trait_name =
|
||||
case trait_type do
|
||||
:charmMin -> :charm
|
||||
:senseMin -> :sense
|
||||
:craftMin -> :craft
|
||||
:willMin -> :will
|
||||
:charismaMin -> :charisma
|
||||
:insightMin -> :insight
|
||||
end
|
||||
|
||||
traits = Map.get(character, :traits, %{})
|
||||
trait_level = Map.get(traits, trait_name, 0)
|
||||
trait_level >= min_level
|
||||
end
|
||||
|
||||
defp do_check(:end, time_str, _character) do
|
||||
# Event end time check
|
||||
if time_str == nil || time_str == "" do
|
||||
true
|
||||
else
|
||||
# Parse YYYYMMDDHH format
|
||||
case String.length(time_str) do
|
||||
10 ->
|
||||
year = String.slice(time_str, 0, 4) |> String.to_integer()
|
||||
month = String.slice(time_str, 4, 2) |> String.to_integer()
|
||||
day = String.slice(time_str, 6, 2) |> String.to_integer()
|
||||
hour = String.slice(time_str, 8, 2) |> String.to_integer()
|
||||
|
||||
end_time = NaiveDateTime.new!(year, month, day, hour, 0, 0)
|
||||
now = NaiveDateTime.utc_now()
|
||||
|
||||
NaiveDateTime.compare(now, end_time) == :lt
|
||||
|
||||
_ ->
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_check(:startscript, _script, _character), do: true
|
||||
defp do_check(:endscript, _script, _character), do: true
|
||||
|
||||
defp do_check(:undefined, _data, _character), do: true
|
||||
|
||||
defp do_check(_type, _data, _character), do: true
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp has_item_count(inventory, item_id, required_count) when required_count > 0 do
|
||||
# Count items across all inventory types
|
||||
total =
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
|
||||
total >= required_count
|
||||
end
|
||||
|
||||
defp has_item_count(inventory, item_id, required_count) when required_count <= 0 do
|
||||
# For negative counts (checking we DON'T have too many)
|
||||
total =
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
|
||||
# If required_count is 0 or negative, we should have 0 of the item
|
||||
# or specifically, not more than the absolute value
|
||||
total <= abs(required_count)
|
||||
end
|
||||
|
||||
@doc "Checks if an item should show in drop for this quest"
|
||||
@spec shows_drop?(t(), integer(), Odinsea.Game.Character.t()) :: boolean()
|
||||
def shows_drop?(%__MODULE__{type: :item} = req, item_id, character) do
|
||||
# Check if this item is needed for the quest and should be shown in drops
|
||||
required_items = req.data
|
||||
|
||||
case Map.get(required_items, item_id) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
required_count ->
|
||||
# Check if player still needs more of this item
|
||||
inventory = Map.get(character, :inventory, %{})
|
||||
current_count = count_items(inventory, item_id)
|
||||
|
||||
# Show drop if player needs more (required > current)
|
||||
# or if required_count is 0/negative (special case)
|
||||
current_count < required_count || required_count <= 0
|
||||
end
|
||||
end
|
||||
|
||||
def shows_drop?(_req, _item_id, _character), do: false
|
||||
|
||||
defp count_items(inventory, item_id) do
|
||||
inventory
|
||||
|> Map.values()
|
||||
|> Enum.flat_map(& &1)
|
||||
|> Enum.filter(fn item -> Map.get(item, :item_id) == item_id end)
|
||||
|> Enum.map(fn item -> Map.get(item, :quantity, 1) end)
|
||||
|> Enum.sum()
|
||||
end
|
||||
end
|
||||
284
lib/odinsea/game/reactor.ex
Normal file
284
lib/odinsea/game/reactor.ex
Normal file
@@ -0,0 +1,284 @@
|
||||
defmodule Odinsea.Game.Reactor do
|
||||
@moduledoc """
|
||||
Represents a reactor instance on a map.
|
||||
|
||||
Reactors are map objects (boxes, rocks, plants) that can be hit/activated by players.
|
||||
They have states, can drop items, trigger scripts, and respawn after time.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactor.java
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.ReactorStats
|
||||
|
||||
@typedoc "Reactor instance struct"
|
||||
@type t :: %__MODULE__{
|
||||
# Identity
|
||||
oid: integer() | nil, # Object ID (assigned by map)
|
||||
reactor_id: integer(), # Reactor template ID
|
||||
|
||||
# State
|
||||
state: integer(), # Current state (byte)
|
||||
alive: boolean(), # Whether reactor is active
|
||||
timer_active: boolean(), # Whether timeout timer is running
|
||||
|
||||
# Position
|
||||
x: integer(), # X position
|
||||
y: integer(), # Y position
|
||||
facing_direction: integer(), # Facing direction (0 or 1)
|
||||
|
||||
# Properties
|
||||
name: String.t(), # Reactor name
|
||||
delay: integer(), # Respawn delay in milliseconds
|
||||
custom: boolean(), # Custom spawned (not from template)
|
||||
|
||||
# Stats reference
|
||||
stats: ReactorStats.t() | nil # Template stats
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:oid,
|
||||
:reactor_id,
|
||||
:stats,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
facing_direction: 0,
|
||||
name: "",
|
||||
delay: -1,
|
||||
custom: false
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new reactor instance from template stats.
|
||||
"""
|
||||
@spec new(integer(), ReactorStats.t()) :: t()
|
||||
def new(reactor_id, stats) do
|
||||
%__MODULE__{
|
||||
reactor_id: reactor_id,
|
||||
stats: stats
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of a reactor (for respawning).
|
||||
"""
|
||||
@spec copy(t()) :: t()
|
||||
def copy(reactor) do
|
||||
%__MODULE__{
|
||||
reactor_id: reactor.reactor_id,
|
||||
stats: reactor.stats,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
x: reactor.x,
|
||||
y: reactor.y,
|
||||
facing_direction: reactor.facing_direction,
|
||||
name: reactor.name,
|
||||
delay: reactor.delay,
|
||||
custom: reactor.custom
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's object ID.
|
||||
"""
|
||||
@spec set_oid(t(), integer()) :: t()
|
||||
def set_oid(reactor, oid) do
|
||||
%{reactor | oid: oid}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's position.
|
||||
"""
|
||||
@spec set_position(t(), integer(), integer()) :: t()
|
||||
def set_position(reactor, x, y) do
|
||||
%{reactor | x: x, y: y}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor's state.
|
||||
"""
|
||||
@spec set_state(t(), integer()) :: t()
|
||||
def set_state(reactor, state) do
|
||||
%{reactor | state: state}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether the reactor is alive.
|
||||
"""
|
||||
@spec set_alive(t(), boolean()) :: t()
|
||||
def set_alive(reactor, alive) do
|
||||
%{reactor | alive: alive}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the facing direction.
|
||||
"""
|
||||
@spec set_facing_direction(t(), integer()) :: t()
|
||||
def set_facing_direction(reactor, direction) do
|
||||
%{reactor | facing_direction: direction}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the reactor name.
|
||||
"""
|
||||
@spec set_name(t(), String.t()) :: t()
|
||||
def set_name(reactor, name) do
|
||||
%{reactor | name: name}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the respawn delay.
|
||||
"""
|
||||
@spec set_delay(t(), integer()) :: t()
|
||||
def set_delay(reactor, delay) do
|
||||
%{reactor | delay: delay}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether this is a custom reactor.
|
||||
"""
|
||||
@spec set_custom(t(), boolean()) :: t()
|
||||
def set_custom(reactor, custom) do
|
||||
%{reactor | custom: custom}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets timer active status.
|
||||
"""
|
||||
@spec set_timer_active(t(), boolean()) :: t()
|
||||
def set_timer_active(reactor, active) do
|
||||
%{reactor | timer_active: active}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the reactor type for the current state.
|
||||
Returns the type value or -1 if stats not loaded.
|
||||
"""
|
||||
@spec get_type(t()) :: integer()
|
||||
def get_type(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_type(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the next state for the current state.
|
||||
"""
|
||||
@spec get_next_state(t()) :: integer()
|
||||
def get_next_state(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_next_state(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the timeout for the current state.
|
||||
"""
|
||||
@spec get_timeout(t()) :: integer()
|
||||
def get_timeout(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_timeout(reactor.stats, reactor.state)
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the touch mode for the current state.
|
||||
Returns: 0 = hit only, 1 = click/touch, 2 = touch only
|
||||
"""
|
||||
@spec can_touch(t()) :: integer()
|
||||
def can_touch(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.can_touch(reactor.stats, reactor.state)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the required item to react for the current state.
|
||||
Returns {item_id, quantity} or nil.
|
||||
"""
|
||||
@spec get_react_item(t()) :: {integer(), integer()} | nil
|
||||
def get_react_item(reactor) do
|
||||
if reactor.stats do
|
||||
ReactorStats.get_react_item(reactor.stats, reactor.state)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Advances to the next state.
|
||||
Returns the updated reactor.
|
||||
"""
|
||||
@spec advance_state(t()) :: t()
|
||||
def advance_state(reactor) do
|
||||
next_state = get_next_state(reactor)
|
||||
if next_state >= 0 do
|
||||
set_state(reactor, next_state)
|
||||
else
|
||||
reactor
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the reactor should trigger a script for the current state.
|
||||
"""
|
||||
@spec should_trigger_script?(t()) :: boolean()
|
||||
def should_trigger_script?(reactor) do
|
||||
type = get_type(reactor)
|
||||
# Type < 100 or type == 999 typically trigger scripts
|
||||
type < 100 or type == 999
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this reactor is in a looping state (state == next_state).
|
||||
"""
|
||||
@spec is_looping?(t()) :: boolean()
|
||||
def is_looping?(reactor) do
|
||||
reactor.state == get_next_state(reactor)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the reactor should be destroyed (next state is -1 or final state).
|
||||
"""
|
||||
@spec should_destroy?(t()) :: boolean()
|
||||
def should_destroy?(reactor) do
|
||||
next = get_next_state(reactor)
|
||||
next == -1 or get_type(reactor) == 999
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the reactor's area of effect (hit box).
|
||||
Returns {tl_x, tl_y, br_x, br_y} or nil if not defined.
|
||||
"""
|
||||
@spec get_area(t()) :: {integer(), integer(), integer(), integer()} | nil
|
||||
def get_area(reactor) do
|
||||
if reactor.stats and reactor.stats.tl and reactor.stats.br do
|
||||
{reactor.stats.tl.x, reactor.stats.tl.y, reactor.stats.br.x, reactor.stats.br.y}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the reactor to initial state (for respawning).
|
||||
"""
|
||||
@spec reset(t()) :: t()
|
||||
def reset(reactor) do
|
||||
%{reactor |
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false
|
||||
}
|
||||
end
|
||||
end
|
||||
276
lib/odinsea/game/reactor_factory.ex
Normal file
276
lib/odinsea/game/reactor_factory.ex
Normal file
@@ -0,0 +1,276 @@
|
||||
defmodule Odinsea.Game.ReactorFactory do
|
||||
@moduledoc """
|
||||
Reactor Factory - loads and caches reactor template data.
|
||||
|
||||
This module loads reactor metadata (states, types, items, timeouts) from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Reactor data is cached in ETS for fast lookups.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactorFactory.java
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{Reactor, ReactorStats}
|
||||
|
||||
# ETS table name
|
||||
@reactor_stats :odinsea_reactor_stats
|
||||
|
||||
# Data file path
|
||||
@reactor_data_file "data/reactors.json"
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the ReactorFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets reactor stats by reactor ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_reactor_stats(integer()) :: ReactorStats.t() | nil
|
||||
def get_reactor_stats(reactor_id) do
|
||||
case :ets.lookup(@reactor_stats, reactor_id) do
|
||||
[{^reactor_id, stats}] -> stats
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor instance by ID.
|
||||
Returns nil if stats not found.
|
||||
"""
|
||||
@spec get_reactor(integer()) :: Reactor.t() | nil
|
||||
def get_reactor(reactor_id) do
|
||||
case get_reactor_stats(reactor_id) do
|
||||
nil -> nil
|
||||
stats -> Reactor.new(reactor_id, stats)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a reactor instance with position and properties.
|
||||
"""
|
||||
@spec create_reactor(integer(), integer(), integer(), integer(), String.t(), integer()) :: Reactor.t() | nil
|
||||
def create_reactor(reactor_id, x, y, facing_direction \\ 0, name \\ "", delay \\ -1) do
|
||||
case get_reactor_stats(reactor_id) do
|
||||
nil ->
|
||||
Logger.warning("Reactor stats not found for reactor_id=#{reactor_id}")
|
||||
nil
|
||||
|
||||
stats ->
|
||||
%Reactor{
|
||||
reactor_id: reactor_id,
|
||||
stats: stats,
|
||||
x: x,
|
||||
y: y,
|
||||
facing_direction: facing_direction,
|
||||
name: name,
|
||||
delay: delay,
|
||||
state: 0,
|
||||
alive: true,
|
||||
timer_active: false,
|
||||
custom: false
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if reactor stats exist.
|
||||
"""
|
||||
@spec reactor_exists?(integer()) :: boolean()
|
||||
def reactor_exists?(reactor_id) do
|
||||
:ets.member(@reactor_stats, reactor_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all loaded reactor IDs.
|
||||
"""
|
||||
@spec get_all_reactor_ids() :: [integer()]
|
||||
def get_all_reactor_ids do
|
||||
:ets.select(@reactor_stats, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of loaded reactors.
|
||||
"""
|
||||
@spec get_reactor_count() :: integer()
|
||||
def get_reactor_count do
|
||||
:ets.info(@reactor_stats, :size)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads reactor data from files.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS table
|
||||
:ets.new(@reactor_stats, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_reactor_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading reactor data...")
|
||||
load_reactor_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_reactor_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
file_path = Path.join(priv_dir, @reactor_data_file)
|
||||
|
||||
load_reactors_from_file(file_path)
|
||||
|
||||
count = :ets.info(@reactor_stats, :size)
|
||||
Logger.info("Loaded #{count} reactor templates")
|
||||
end
|
||||
|
||||
defp load_reactors_from_file(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, reactors} when is_map(reactors) ->
|
||||
# Clear existing data
|
||||
:ets.delete_all_objects(@reactor_stats)
|
||||
|
||||
# Load reactors and handle links
|
||||
links = process_reactors(reactors, %{})
|
||||
|
||||
# Resolve links
|
||||
resolve_links(links)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to parse reactors JSON: #{inspect(reason)}")
|
||||
create_fallback_reactors()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warning("Reactors file not found: #{file_path}, using fallback data")
|
||||
create_fallback_reactors()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read reactors: #{inspect(reason)}")
|
||||
create_fallback_reactors()
|
||||
end
|
||||
end
|
||||
|
||||
defp process_reactors(reactors, links) do
|
||||
Enum.reduce(reactors, links, fn {reactor_id_str, reactor_data}, acc_links ->
|
||||
reactor_id = String.to_integer(reactor_id_str)
|
||||
|
||||
# Check if this is a link to another reactor
|
||||
link_target = reactor_data["link"]
|
||||
|
||||
if link_target && link_target > 0 do
|
||||
# Store link for later resolution
|
||||
Map.put(acc_links, reactor_id, link_target)
|
||||
else
|
||||
# Build stats from data
|
||||
stats = ReactorStats.from_json(reactor_data)
|
||||
:ets.insert(@reactor_stats, {reactor_id, stats})
|
||||
acc_links
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_links(links) do
|
||||
Enum.each(links, fn {reactor_id, target_id} ->
|
||||
case :ets.lookup(@reactor_stats, target_id) do
|
||||
[{^target_id, target_stats}] ->
|
||||
# Copy target stats for linked reactor
|
||||
:ets.insert(@reactor_stats, {reactor_id, target_stats})
|
||||
|
||||
[] ->
|
||||
Logger.warning("Link target not found: #{target_id} for reactor #{reactor_id}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_reactors do
|
||||
# Common reactors from MapleStory
|
||||
fallback_reactors = [
|
||||
%{
|
||||
reactor_id: 100000, # Normal box
|
||||
states: %{
|
||||
"0" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200000, # Herb
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200100, # Vein
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200200, # Gold Flower
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200300, # Silver Flower
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 100011, # Mysterious Herb
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
},
|
||||
%{
|
||||
reactor_id: 200011, # Mysterious Vein
|
||||
activate_by_touch: true,
|
||||
states: %{
|
||||
"0" => %{type: 2, next_state: 1, timeout: -1, can_touch: 2},
|
||||
"1" => %{type: 999, next_state: -1, timeout: -1, can_touch: 0}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_reactors, fn reactor_data ->
|
||||
stats = ReactorStats.from_json(reactor_data)
|
||||
:ets.insert(@reactor_stats, {reactor_data.reactor_id, stats})
|
||||
end)
|
||||
|
||||
Logger.info("Created #{length(fallback_reactors)} fallback reactor templates")
|
||||
end
|
||||
end
|
||||
252
lib/odinsea/game/reactor_stats.ex
Normal file
252
lib/odinsea/game/reactor_stats.ex
Normal file
@@ -0,0 +1,252 @@
|
||||
defmodule Odinsea.Game.ReactorStats do
|
||||
@moduledoc """
|
||||
Represents reactor template stats (state machine data).
|
||||
|
||||
Contains the state definitions for a reactor type.
|
||||
Each state defines: type, next state, required item, timeout, touch mode.
|
||||
|
||||
Ported from Java: src/server/maps/MapleReactorStats.java
|
||||
"""
|
||||
|
||||
defmodule Point do
|
||||
@moduledoc "Simple 2D point for area bounds"
|
||||
@type t :: %__MODULE__{x: integer(), y: integer()}
|
||||
defstruct [:x, :y]
|
||||
end
|
||||
|
||||
defmodule StateData do
|
||||
@moduledoc "State definition for a reactor"
|
||||
@type t :: %__MODULE__{
|
||||
type: integer(), # State type (determines behavior)
|
||||
next_state: integer(), # Next state index (-1 = end)
|
||||
react_item: {integer(), integer()} | nil, # {item_id, quantity} required
|
||||
timeout: integer(), # Timeout in ms before auto-advance (-1 = none)
|
||||
can_touch: integer() # 0 = hit only, 1 = click/touch, 2 = touch only
|
||||
}
|
||||
defstruct [
|
||||
:type,
|
||||
:next_state,
|
||||
:react_item,
|
||||
timeout: -1,
|
||||
can_touch: 0
|
||||
]
|
||||
end
|
||||
|
||||
@typedoc "Reactor stats struct"
|
||||
@type t :: %__MODULE__{
|
||||
tl: Point.t() | nil, # Top-left corner of area (for item-triggered)
|
||||
br: Point.t() | nil, # Bottom-right corner of area
|
||||
states: %{integer() => StateData.t()}, # State definitions by state number
|
||||
activate_by_touch: boolean() # Whether reactor activates by touch
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:tl,
|
||||
:br,
|
||||
states: %{},
|
||||
activate_by_touch: false
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new empty reactor stats.
|
||||
"""
|
||||
@spec new() :: t()
|
||||
def new do
|
||||
%__MODULE__{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the top-left point of the area.
|
||||
"""
|
||||
@spec set_tl(t(), integer(), integer()) :: t()
|
||||
def set_tl(stats, x, y) do
|
||||
%{stats | tl: %Point{x: x, y: y}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the bottom-right point of the area.
|
||||
"""
|
||||
@spec set_br(t(), integer(), integer()) :: t()
|
||||
def set_br(stats, x, y) do
|
||||
%{stats | br: %Point{x: x, y: y}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets whether reactor activates by touch.
|
||||
"""
|
||||
@spec set_activate_by_touch(t(), boolean()) :: t()
|
||||
def set_activate_by_touch(stats, activate) do
|
||||
%{stats | activate_by_touch: activate}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a state definition.
|
||||
|
||||
## Parameters
|
||||
- stats: the reactor stats struct
|
||||
- state_num: the state number (byte value)
|
||||
- type: the state type (determines behavior)
|
||||
- react_item: {item_id, quantity} or nil
|
||||
- next_state: the next state number (-1 for end)
|
||||
- timeout: timeout in ms (-1 for none)
|
||||
- can_touch: 0 = hit only, 1 = click, 2 = touch only
|
||||
"""
|
||||
@spec add_state(
|
||||
t(),
|
||||
integer(),
|
||||
integer(),
|
||||
{integer(), integer()} | nil,
|
||||
integer(),
|
||||
integer(),
|
||||
integer()
|
||||
) :: t()
|
||||
def add_state(stats, state_num, type, react_item, next_state, timeout, can_touch) do
|
||||
state_data = %StateData{
|
||||
type: type,
|
||||
react_item: react_item,
|
||||
next_state: next_state,
|
||||
timeout: timeout,
|
||||
can_touch: can_touch
|
||||
}
|
||||
|
||||
%{stats | states: Map.put(stats.states, state_num, state_data)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the next state for a given current state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_next_state(t(), integer()) :: integer()
|
||||
def get_next_state(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.next_state
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the type for a given state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_type(t(), integer()) :: integer()
|
||||
def get_type(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.type
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the react item for a given state.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_react_item(t(), integer()) :: {integer(), integer()} | nil
|
||||
def get_react_item(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> nil
|
||||
state_data -> state_data.react_item
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the timeout for a given state.
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
@spec get_timeout(t(), integer()) :: integer()
|
||||
def get_timeout(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> -1
|
||||
state_data -> state_data.timeout
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the touch mode for a given state.
|
||||
Returns 0 if not found.
|
||||
|
||||
Modes:
|
||||
- 0: Hit only (weapon attack)
|
||||
- 1: Click/touch (interact button)
|
||||
- 2: Touch only (walk into)
|
||||
"""
|
||||
@spec can_touch(t(), integer()) :: integer()
|
||||
def can_touch(stats, state) do
|
||||
case Map.get(stats.states, state) do
|
||||
nil -> 0
|
||||
state_data -> state_data.can_touch
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all state numbers defined for this reactor.
|
||||
"""
|
||||
@spec get_state_numbers(t()) :: [integer()]
|
||||
def get_state_numbers(stats) do
|
||||
Map.keys(stats.states) |> Enum.sort()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a state exists.
|
||||
"""
|
||||
@spec has_state?(t(), integer()) :: boolean()
|
||||
def has_state?(stats, state) do
|
||||
Map.has_key?(stats.states, state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the state data for a given state number.
|
||||
"""
|
||||
@spec get_state_data(t(), integer()) :: StateData.t() | nil
|
||||
def get_state_data(stats, state) do
|
||||
Map.get(stats.states, state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds reactor stats from JSON data.
|
||||
"""
|
||||
@spec from_json(map()) :: t()
|
||||
def from_json(data) do
|
||||
stats = new()
|
||||
|
||||
# Set activate by touch
|
||||
stats = set_activate_by_touch(stats, data["activate_by_touch"] == true)
|
||||
|
||||
# Set area bounds if present
|
||||
stats =
|
||||
if data["tl"] do
|
||||
set_tl(stats, data["tl"]["x"] || 0, data["tl"]["y"] || 0)
|
||||
else
|
||||
stats
|
||||
end
|
||||
|
||||
stats =
|
||||
if data["br"] do
|
||||
set_br(stats, data["br"]["x"] || 0, data["br"]["y"] || 0)
|
||||
else
|
||||
stats
|
||||
end
|
||||
|
||||
# Add states
|
||||
states = data["states"] || %{}
|
||||
|
||||
Enum.reduce(states, stats, fn {state_num_str, state_data}, acc_stats ->
|
||||
state_num = String.to_integer(state_num_str)
|
||||
|
||||
type = state_data["type"] || 999
|
||||
next_state = state_data["next_state"] || -1
|
||||
timeout = state_data["timeout"] || -1
|
||||
can_touch = state_data["can_touch"] || 0
|
||||
|
||||
react_item =
|
||||
if state_data["react_item"] do
|
||||
item_id = state_data["react_item"]["item_id"]
|
||||
quantity = state_data["react_item"]["quantity"] || 1
|
||||
if item_id, do: {item_id, quantity}, else: nil
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
add_state(acc_stats, state_num, type, react_item, next_state, timeout, can_touch)
|
||||
end)
|
||||
end
|
||||
end
|
||||
112
lib/odinsea/game/shop_item.ex
Normal file
112
lib/odinsea/game/shop_item.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule Odinsea.Game.ShopItem do
|
||||
@moduledoc """
|
||||
Represents an item listed in a player shop or hired merchant.
|
||||
Ported from src/server/shops/MaplePlayerShopItem.java
|
||||
|
||||
Each shop item contains:
|
||||
- The actual item data
|
||||
- Number of bundles (how many stacks)
|
||||
- Price per bundle
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.{Item, Equip}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item: Item.t() | Equip.t(),
|
||||
bundles: integer(),
|
||||
price: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:item,
|
||||
:bundles,
|
||||
:price
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new shop item.
|
||||
"""
|
||||
def new(item, bundles, price) do
|
||||
%__MODULE__{
|
||||
item: item,
|
||||
bundles: bundles,
|
||||
price: price
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the total quantity available (bundles * quantity per bundle).
|
||||
"""
|
||||
def total_quantity(%__MODULE__{} = shop_item) do
|
||||
per_bundle = shop_item.item.quantity
|
||||
shop_item.bundles * per_bundle
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the total price for a given quantity.
|
||||
"""
|
||||
def calculate_price(%__MODULE__{} = shop_item, quantity) do
|
||||
shop_item.price * quantity
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reduces the number of bundles by the given quantity.
|
||||
Returns the updated shop item.
|
||||
"""
|
||||
def reduce_bundles(%__MODULE__{} = shop_item, quantity) do
|
||||
%{shop_item | bundles: shop_item.bundles - quantity}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the item is sold out (no bundles remaining).
|
||||
"""
|
||||
def sold_out?(%__MODULE__{} = shop_item) do
|
||||
shop_item.bundles <= 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a copy of the item for the buyer.
|
||||
The copy has the quantity adjusted based on bundles purchased.
|
||||
"""
|
||||
def create_buyer_item(%__MODULE__{} = shop_item, quantity) do
|
||||
item_copy = copy_item(shop_item.item)
|
||||
per_bundle = shop_item.item.quantity
|
||||
total_qty = quantity * per_bundle
|
||||
|
||||
case item_copy do
|
||||
%{quantity: _} = item ->
|
||||
%{item | quantity: total_qty}
|
||||
|
||||
equip ->
|
||||
# Equipment doesn't have quantity field
|
||||
equip
|
||||
end
|
||||
end
|
||||
|
||||
defp copy_item(%Item{} = item), do: Item.copy(item)
|
||||
defp copy_item(%Equip{} = equip), do: Equip.copy(equip)
|
||||
defp copy_item(item), do: item
|
||||
|
||||
@doc """
|
||||
Removes karma flags from an item (for trade).
|
||||
"""
|
||||
def remove_karma(%__MODULE__{} = shop_item) do
|
||||
item = shop_item.item
|
||||
|
||||
updated_item =
|
||||
cond do
|
||||
# KARMA_EQ flag = 0x02
|
||||
Bitwise.band(item.flag, 0x02) != 0 ->
|
||||
%{item | flag: item.flag - 0x02}
|
||||
|
||||
# KARMA_USE flag = 0x04
|
||||
Bitwise.band(item.flag, 0x04) != 0 ->
|
||||
%{item | flag: item.flag - 0x04}
|
||||
|
||||
true ->
|
||||
item
|
||||
end
|
||||
|
||||
%{shop_item | item: updated_item}
|
||||
end
|
||||
end
|
||||
271
lib/odinsea/game/skill.ex
Normal file
271
lib/odinsea/game/skill.ex
Normal file
@@ -0,0 +1,271 @@
|
||||
defmodule Odinsea.Game.Skill do
|
||||
@moduledoc """
|
||||
Skill struct and functions for MapleStory skills.
|
||||
|
||||
Ported from Java: client/Skill.java
|
||||
|
||||
Skills are abilities that characters can learn and use. Each skill has:
|
||||
- Multiple levels with increasing effects
|
||||
- Requirements (job, level, other skills)
|
||||
- Effects (buffs, damage, healing, etc.)
|
||||
- Animation data
|
||||
- Cooldowns and durations
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.StatEffect
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:name,
|
||||
:element,
|
||||
:max_level,
|
||||
:true_max,
|
||||
:master_level,
|
||||
:effects,
|
||||
:pvp_effects,
|
||||
:required_skills,
|
||||
:skill_type,
|
||||
:animation,
|
||||
:animation_time,
|
||||
:delay,
|
||||
:invisible,
|
||||
:time_limited,
|
||||
:combat_orders,
|
||||
:charge_skill,
|
||||
:magic,
|
||||
:caster_move,
|
||||
:push_target,
|
||||
:pull_target,
|
||||
:not_removed,
|
||||
:pvp_disabled,
|
||||
:event_taming_mob
|
||||
]
|
||||
|
||||
@type element :: :neutral | :fire | :ice | :lightning | :poison | :holy | :dark | :physical
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
name: String.t(),
|
||||
element: element(),
|
||||
max_level: integer(),
|
||||
true_max: integer(),
|
||||
master_level: integer(),
|
||||
effects: [StatEffect.t()],
|
||||
pvp_effects: [StatEffect.t()] | nil,
|
||||
required_skills: [{integer(), integer()}],
|
||||
skill_type: integer(),
|
||||
animation: [{String.t(), integer()}] | nil,
|
||||
animation_time: integer(),
|
||||
delay: integer(),
|
||||
invisible: boolean(),
|
||||
time_limited: boolean(),
|
||||
combat_orders: boolean(),
|
||||
charge_skill: boolean(),
|
||||
magic: boolean(),
|
||||
caster_move: boolean(),
|
||||
push_target: boolean(),
|
||||
pull_target: boolean(),
|
||||
not_removed: boolean(),
|
||||
pvp_disabled: boolean(),
|
||||
event_taming_mob: integer()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new skill with the given ID and default values.
|
||||
"""
|
||||
@spec new(integer()) :: t()
|
||||
def new(id) do
|
||||
%__MODULE__{
|
||||
id: id,
|
||||
name: "",
|
||||
element: :neutral,
|
||||
max_level: 0,
|
||||
true_max: 0,
|
||||
master_level: 0,
|
||||
effects: [],
|
||||
pvp_effects: nil,
|
||||
required_skills: [],
|
||||
skill_type: 0,
|
||||
animation: nil,
|
||||
animation_time: 0,
|
||||
delay: 0,
|
||||
invisible: false,
|
||||
time_limited: false,
|
||||
combat_orders: false,
|
||||
charge_skill: false,
|
||||
magic: false,
|
||||
caster_move: false,
|
||||
push_target: false,
|
||||
pull_target: false,
|
||||
not_removed: false,
|
||||
pvp_disabled: false,
|
||||
event_taming_mob: 0
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the effect for a specific skill level.
|
||||
Returns the last effect if level exceeds max, or first effect if level <= 0.
|
||||
"""
|
||||
@spec get_effect(t(), integer()) :: StatEffect.t() | nil
|
||||
def get_effect(skill, level) do
|
||||
effects = skill.effects
|
||||
|
||||
cond do
|
||||
length(effects) == 0 -> nil
|
||||
level <= 0 -> List.first(effects)
|
||||
level > length(effects) -> List.last(effects)
|
||||
true -> Enum.at(effects, level - 1)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the PVP effect for a specific skill level.
|
||||
Falls back to regular effects if PVP effects not defined.
|
||||
"""
|
||||
@spec get_pvp_effect(t(), integer()) :: StatEffect.t() | nil
|
||||
def get_pvp_effect(skill, level) do
|
||||
if skill.pvp_effects do
|
||||
cond do
|
||||
level <= 0 -> List.first(skill.pvp_effects)
|
||||
level > length(skill.pvp_effects) -> List.last(skill.pvp_effects)
|
||||
true -> Enum.at(skill.pvp_effects, level - 1)
|
||||
end
|
||||
else
|
||||
get_effect(skill, level)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this skill can be learned by a specific job.
|
||||
"""
|
||||
@spec can_be_learned_by?(t(), integer()) :: boolean()
|
||||
def can_be_learned_by?(skill, job_id) do
|
||||
skill_job = div(skill.id, 10000)
|
||||
|
||||
# Special job exceptions
|
||||
cond do
|
||||
# Evan beginner skills
|
||||
skill_job == 2001 -> is_evan_job?(job_id)
|
||||
# Regular beginner skills (adventurer)
|
||||
skill_job == 0 -> is_adventurer_job?(job_id)
|
||||
# Cygnus beginner skills
|
||||
skill_job == 1000 -> is_cygnus_job?(job_id)
|
||||
# Aran beginner skills
|
||||
skill_job == 2000 -> is_aran_job?(job_id)
|
||||
# Resistance beginner skills
|
||||
skill_job == 3000 -> is_resistance_job?(job_id)
|
||||
# Cannon shooter beginner
|
||||
skill_job == 1 -> is_cannon_job?(job_id)
|
||||
# Demon beginner
|
||||
skill_job == 3001 -> is_demon_job?(job_id)
|
||||
# Mercedes beginner
|
||||
skill_job == 2002 -> is_mercedes_job?(job_id)
|
||||
# Wrong job category
|
||||
div(job_id, 100) != div(skill_job, 100) -> false
|
||||
div(job_id, 1000) != div(skill_job, 1000) -> false
|
||||
# Class-specific restrictions
|
||||
is_cannon_job?(skill_job) and not is_cannon_job?(job_id) -> false
|
||||
is_demon_job?(skill_job) and not is_demon_job?(job_id) -> false
|
||||
is_adventurer_job?(skill_job) and not is_adventurer_job?(job_id) -> false
|
||||
is_cygnus_job?(skill_job) and not is_cygnus_job?(job_id) -> false
|
||||
is_aran_job?(skill_job) and not is_aran_job?(job_id) -> false
|
||||
is_evan_job?(skill_job) and not is_evan_job?(job_id) -> false
|
||||
is_mercedes_job?(skill_job) and not is_mercedes_job?(job_id) -> false
|
||||
is_resistance_job?(skill_job) and not is_resistance_job?(job_id) -> false
|
||||
# Wrong 2nd job
|
||||
rem(div(job_id, 10), 10) == 0 and rem(div(skill_job, 10), 10) > rem(div(job_id, 10), 10) -> false
|
||||
rem(div(skill_job, 10), 10) != 0 and rem(div(skill_job, 10), 10) != rem(div(job_id, 10), 10) -> false
|
||||
# Wrong 3rd/4th job
|
||||
rem(skill_job, 10) > rem(job_id, 10) -> false
|
||||
true -> true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a fourth job skill.
|
||||
"""
|
||||
@spec is_fourth_job?(t()) :: boolean()
|
||||
def is_fourth_job?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
cond do
|
||||
# All 10 skills for 2312 (Phantom)
|
||||
job_id == 2312 -> true
|
||||
# Skills with max level <= 15 and no master level
|
||||
skill.max_level <= 15 and not skill.invisible and skill.master_level <= 0 -> false
|
||||
# Specific exceptions
|
||||
skill.id in [3_220_010, 3_120_011, 33_120_010, 32_120_009, 5_321_006, 21_120_011, 22_181_004, 4_340_010] -> false
|
||||
# Evan skills
|
||||
job_id >= 2212 and job_id < 3000 -> rem(job_id, 10) >= 7
|
||||
# Dual Blade skills
|
||||
job_id >= 430 and job_id <= 434 -> rem(job_id, 10) == 4 or skill.master_level > 0
|
||||
# Standard 4th job detection
|
||||
rem(job_id, 10) == 2 and skill.id < 90_000_000 and not is_beginner_skill?(skill) -> true
|
||||
true -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a beginner skill.
|
||||
"""
|
||||
@spec is_beginner_skill?(t()) :: boolean()
|
||||
def is_beginner_skill?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if skill has required skills that must be learned first.
|
||||
"""
|
||||
@spec has_required_skill?(t()) :: boolean()
|
||||
def has_required_skill?(skill) do
|
||||
length(skill.required_skills) > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default skill expiration time for time-limited skills.
|
||||
Returns -1 for permanent skills, or 30 days from now for time-limited.
|
||||
"""
|
||||
@spec get_default_expiry(t()) :: integer()
|
||||
def get_default_expiry(skill) do
|
||||
if skill.time_limited do
|
||||
# 30 days in milliseconds
|
||||
System.system_time(:millisecond) + 30 * 24 * 60 * 60 * 1000
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a special skill (GM, admin, etc).
|
||||
"""
|
||||
@spec is_special_skill?(t()) :: boolean()
|
||||
def is_special_skill?(skill) do
|
||||
job_id = div(skill.id, 10000)
|
||||
job_id in [900, 800, 9000, 9200, 9201, 9202, 9203, 9204]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random animation from the skill's animation list.
|
||||
"""
|
||||
@spec get_animation(t()) :: integer() | nil
|
||||
def get_animation(skill) do
|
||||
if skill.animation && length(skill.animation) > 0 do
|
||||
{_, delay} = Enum.random(skill.animation)
|
||||
delay
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Job type checks
|
||||
defp is_evan_job?(job_id), do: div(job_id, 100) == 22 or job_id == 2001
|
||||
defp is_adventurer_job?(job_id), do: div(job_id, 1000) == 0 and job_id not in [1]
|
||||
defp is_cygnus_job?(job_id), do: div(job_id, 1000) == 1
|
||||
defp is_aran_job?(job_id), do: div(job_id, 100) == 21 or job_id == 2000
|
||||
defp is_resistance_job?(job_id), do: div(job_id, 1000) == 3
|
||||
defp is_cannon_job?(job_id), do: div(job_id, 100) == 53 or job_id == 1
|
||||
defp is_demon_job?(job_id), do: div(job_id, 100) == 31 or job_id == 3001
|
||||
defp is_mercedes_job?(job_id), do: div(job_id, 100) == 23 or job_id == 2002
|
||||
end
|
||||
675
lib/odinsea/game/skill_factory.ex
Normal file
675
lib/odinsea/game/skill_factory.ex
Normal file
@@ -0,0 +1,675 @@
|
||||
defmodule Odinsea.Game.SkillFactory do
|
||||
@moduledoc """
|
||||
Skill Factory - loads and caches skill data.
|
||||
|
||||
Ported from Java: client/SkillFactory.java
|
||||
|
||||
This module loads skill metadata from cached JSON files.
|
||||
The JSON files should be exported from the Java server's WZ data providers.
|
||||
|
||||
Skill data is cached in ETS for fast lookups.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.{Skill, StatEffect}
|
||||
|
||||
# ETS table names
|
||||
@skill_cache :odinsea_skill_cache
|
||||
@skill_names :odinsea_skill_names
|
||||
@skills_by_job :odinsea_skills_by_job
|
||||
@summon_skills :odinsea_summon_skills
|
||||
|
||||
# Data file paths (relative to priv directory)
|
||||
@skill_data_file "data/skills.json"
|
||||
@skill_strings_file "data/skill_strings.json"
|
||||
|
||||
defmodule SummonSkillEntry do
|
||||
@moduledoc "Summon skill attack data"
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
skill_id: integer(),
|
||||
type: integer(),
|
||||
mob_count: integer(),
|
||||
attack_count: integer(),
|
||||
lt: {integer(), integer()},
|
||||
rb: {integer(), integer()},
|
||||
delay: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:skill_id,
|
||||
:type,
|
||||
:mob_count,
|
||||
:attack_count,
|
||||
:lt,
|
||||
:rb,
|
||||
:delay
|
||||
]
|
||||
end
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the SkillFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a skill by ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
@spec get_skill(integer()) :: Skill.t() | nil
|
||||
def get_skill(skill_id) do
|
||||
case :ets.lookup(@skill_cache, skill_id) do
|
||||
[{^skill_id, skill}] -> skill
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets skill name by ID.
|
||||
"""
|
||||
@spec get_skill_name(integer()) :: String.t()
|
||||
def get_skill_name(skill_id) do
|
||||
case :ets.lookup(@skill_names, skill_id) do
|
||||
[{^skill_id, name}] -> name
|
||||
[] -> "UNKNOWN"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all skills for a specific job.
|
||||
"""
|
||||
@spec get_skills_by_job(integer()) :: [integer()]
|
||||
def get_skills_by_job(job_id) do
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, skills}] -> skills
|
||||
[] -> []
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets summon skill entry for a skill ID.
|
||||
"""
|
||||
@spec get_summon_data(integer()) :: SummonSkillEntry.t() | nil
|
||||
def get_summon_data(skill_id) do
|
||||
case :ets.lookup(@summon_skills, skill_id) do
|
||||
[{^skill_id, entry}] -> entry
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a skill exists.
|
||||
"""
|
||||
@spec skill_exists?(integer()) :: boolean()
|
||||
def skill_exists?(skill_id) do
|
||||
:ets.member(@skill_cache, skill_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all loaded skill IDs.
|
||||
"""
|
||||
@spec get_all_skill_ids() :: [integer()]
|
||||
def get_all_skill_ids do
|
||||
:ets.select(@skill_cache, [{{:"$1", :_}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets skill effect for a specific level.
|
||||
Convenience function that combines get_skill and Skill.get_effect.
|
||||
"""
|
||||
@spec get_effect(integer(), integer()) :: StatEffect.t() | nil
|
||||
def get_effect(skill_id, level) do
|
||||
case get_skill(skill_id) do
|
||||
nil -> nil
|
||||
skill -> Skill.get_effect(skill, level)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a skill is a beginner skill.
|
||||
"""
|
||||
@spec is_beginner_skill?(integer()) :: boolean()
|
||||
def is_beginner_skill?(skill_id) do
|
||||
job_id = div(skill_id, 10000)
|
||||
job_id in [0, 1000, 2000, 2001, 2002, 3000, 3001, 1]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the job ID for a skill.
|
||||
"""
|
||||
@spec get_skill_job(integer()) :: integer()
|
||||
def get_skill_job(skill_id) do
|
||||
div(skill_id, 10000)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads skill data from files.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@skill_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@skill_names, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@skills_by_job, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@summon_skills, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_skill_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading skill data...")
|
||||
load_skill_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_skill_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
# Try to load from JSON files
|
||||
load_skill_strings(Path.join(priv_dir, @skill_strings_file))
|
||||
load_skills(Path.join(priv_dir, @skill_data_file))
|
||||
|
||||
skill_count = :ets.info(@skill_cache, :size)
|
||||
Logger.info("Loaded #{skill_count} skills")
|
||||
end
|
||||
|
||||
defp load_skill_strings(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content) do
|
||||
{:ok, data} when is_map(data) ->
|
||||
Enum.each(data, fn {id_str, name} ->
|
||||
case Integer.parse(id_str) do
|
||||
{skill_id, ""} -> :ets.insert(@skill_names, {skill_id, name})
|
||||
_ -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse skill strings JSON: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Skill strings file not found: #{file_path}, using fallback data")
|
||||
create_fallback_strings()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read skill strings: #{inspect(reason)}")
|
||||
create_fallback_strings()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_skills(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, skills} when is_list(skills) ->
|
||||
Enum.each(skills, fn skill_data ->
|
||||
skill = build_skill(skill_data)
|
||||
:ets.insert(@skill_cache, {skill.id, skill})
|
||||
|
||||
# Index by job
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
existing =
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, list}] -> list
|
||||
[] -> []
|
||||
end
|
||||
|
||||
:ets.insert(@skills_by_job, {job_id, [skill.id | existing]})
|
||||
|
||||
# Check for summon data
|
||||
if skill_data[:summon] do
|
||||
entry = build_summon_entry(skill.id, skill_data[:summon])
|
||||
:ets.insert(@summon_skills, {skill.id, entry})
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse skills JSON: #{inspect(reason)}")
|
||||
create_fallback_skills()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Skills file not found: #{file_path}, using fallback data")
|
||||
create_fallback_skills()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read skills: #{inspect(reason)}")
|
||||
create_fallback_skills()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_skill(data) do
|
||||
effects =
|
||||
(data[:effects] || [])
|
||||
|> Enum.map(&build_stat_effect/1)
|
||||
|
||||
pvp_effects =
|
||||
if data[:pvp_effects] do
|
||||
Enum.map(data[:pvp_effects], &build_stat_effect/1)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%Skill{
|
||||
id: data[:id] || data[:skill_id] || 0,
|
||||
name: data[:name] || "",
|
||||
element: parse_element(data[:element]),
|
||||
max_level: data[:max_level] || 0,
|
||||
true_max: data[:true_max] || data[:max_level] || 0,
|
||||
master_level: data[:master_level] || 0,
|
||||
effects: effects,
|
||||
pvp_effects: pvp_effects,
|
||||
required_skills: data[:required_skills] || [],
|
||||
skill_type: data[:skill_type] || 0,
|
||||
animation: data[:animation],
|
||||
animation_time: data[:animation_time] || 0,
|
||||
delay: data[:delay] || 0,
|
||||
invisible: data[:invisible] || false,
|
||||
time_limited: data[:time_limited] || false,
|
||||
combat_orders: data[:combat_orders] || false,
|
||||
charge_skill: data[:charge_skill] || false,
|
||||
magic: data[:magic] || false,
|
||||
caster_move: data[:caster_move] || false,
|
||||
push_target: data[:push_target] || false,
|
||||
pull_target: data[:pull_target] || false,
|
||||
not_removed: data[:not_removed] || false,
|
||||
pvp_disabled: data[:pvp_disabled] || false,
|
||||
event_taming_mob: data[:event_taming_mob] || 0
|
||||
}
|
||||
end
|
||||
|
||||
defp build_stat_effect(data) do
|
||||
%StatEffect{
|
||||
source_id: data[:source_id] || 0,
|
||||
level: data[:level] || 1,
|
||||
is_skill: data[:is_skill] || true,
|
||||
duration: data[:duration] || -1,
|
||||
over_time: data[:over_time] || false,
|
||||
hp: data[:hp] || 0,
|
||||
mp: data[:mp] || 0,
|
||||
hp_r: data[:hp_r] || 0.0,
|
||||
mp_r: data[:mp_r] || 0.0,
|
||||
mhp_r: data[:mhp_r] || 0,
|
||||
mmp_r: data[:mmp_r] || 0,
|
||||
watk: data[:watk] || data[:pad] || 0,
|
||||
wdef: data[:wdef] || data[:pdd] || 0,
|
||||
matk: data[:matk] || data[:mad] || 0,
|
||||
mdef: data[:mdef] || data[:mdd] || 0,
|
||||
acc: data[:acc] || 0,
|
||||
avoid: data[:avoid] || data[:eva] || 0,
|
||||
hands: data[:hands] || 0,
|
||||
speed: data[:speed] || 0,
|
||||
jump: data[:jump] || 0,
|
||||
mastery: data[:mastery] || 0,
|
||||
damage: data[:damage] || 100,
|
||||
pdd_r: data[:pdd_r] || 0,
|
||||
mdd_r: data[:mdd_r] || 0,
|
||||
dam_r: data[:dam_r] || 0,
|
||||
bd_r: data[:bd_r] || 0,
|
||||
ignore_mob: data[:ignore_mob] || 0,
|
||||
critical_damage_min: data[:critical_damage_min] || 0,
|
||||
critical_damage_max: data[:critical_damage_max] || 0,
|
||||
asr_r: data[:asr_r] || 0,
|
||||
er: data[:er] || 0,
|
||||
prop: data[:prop] || 100,
|
||||
mob_count: data[:mob_count] || 1,
|
||||
attack_count: data[:attack_count] || 1,
|
||||
bullet_count: data[:bullet_count] || 1,
|
||||
cooldown: data[:cooldown] || data[:cooltime] || 0,
|
||||
interval: data[:interval] || 0,
|
||||
mp_con: data[:mp_con] || 0,
|
||||
hp_con: data[:hp_con] || 0,
|
||||
force_con: data[:force_con] || 0,
|
||||
mp_con_reduce: data[:mp_con_reduce] || 0,
|
||||
move_to: data[:move_to] || -1,
|
||||
morph_id: data[:morph] || data[:morph_id] || 0,
|
||||
summon_movement_type: parse_summon_movement(data[:summon_movement]),
|
||||
dot: data[:dot] || 0,
|
||||
dot_time: data[:dot_time] || 0,
|
||||
thaw: data[:thaw] || 0,
|
||||
self_destruction: data[:self_destruction] || 0,
|
||||
pvp_damage: data[:pvp_damage] || 0,
|
||||
inc_pvp_damage: data[:inc_pvp_damage] || 0,
|
||||
indie_pad: data[:indie_pad] || 0,
|
||||
indie_mad: data[:indie_mad] || 0,
|
||||
indie_mhp: data[:indie_mhp] || 0,
|
||||
indie_mmp: data[:indie_mmp] || 0,
|
||||
indie_speed: data[:indie_speed] || 0,
|
||||
indie_jump: data[:indie_jump] || 0,
|
||||
indie_acc: data[:indie_acc] || 0,
|
||||
indie_eva: data[:indie_eva] || 0,
|
||||
indie_pdd: data[:indie_pdd] || 0,
|
||||
indie_mdd: data[:indie_mdd] || 0,
|
||||
indie_all_stat: data[:indie_all_stat] || 0,
|
||||
str: data[:str] || 0,
|
||||
dex: data[:dex] || 0,
|
||||
int: data[:int] || 0,
|
||||
luk: data[:luk] || 0,
|
||||
str_x: data[:str_x] || 0,
|
||||
dex_x: data[:dex_x] || 0,
|
||||
int_x: data[:int_x] || 0,
|
||||
luk_x: data[:luk_x] || 0,
|
||||
x: data[:x] || 0,
|
||||
y: data[:y] || 0,
|
||||
z: data[:z] || 0,
|
||||
stat_ups: data[:stat_ups] || %{},
|
||||
monster_status: data[:monster_status] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp build_summon_entry(skill_id, summon_data) do
|
||||
%SummonSkillEntry{
|
||||
skill_id: skill_id,
|
||||
type: summon_data[:type] || 0,
|
||||
mob_count: summon_data[:mob_count] || 1,
|
||||
attack_count: summon_data[:attack_count] || 1,
|
||||
lt: parse_point(summon_data[:lt]) || {-100, -100},
|
||||
rb: parse_point(summon_data[:rb]) || {100, 100},
|
||||
delay: summon_data[:delay] || 0
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_element(nil), do: :neutral
|
||||
defp parse_element("f"), do: :fire
|
||||
defp parse_element("i"), do: :ice
|
||||
defp parse_element("l"), do: :lightning
|
||||
defp parse_element("p"), do: :poison
|
||||
defp parse_element("h"), do: :holy
|
||||
defp parse_element("d"), do: :dark
|
||||
defp parse_element("s"), do: :physical
|
||||
defp parse_element(atom) when is_atom(atom), do: atom
|
||||
defp parse_element(_), do: :neutral
|
||||
|
||||
defp parse_summon_movement(nil), do: nil
|
||||
defp parse_summon_movement("follow"), do: :follow
|
||||
defp parse_summon_movement("stationary"), do: :stationary
|
||||
defp parse_summon_movement("circle_follow"), do: :circle_follow
|
||||
defp parse_summon_movement(atom) when is_atom(atom), do: atom
|
||||
defp parse_summon_movement(_), do: nil
|
||||
|
||||
defp parse_point(nil), do: nil
|
||||
defp parse_point({x, y}), do: {x, y}
|
||||
defp parse_point([x, y]), do: {x, y}
|
||||
defp parse_point(%{x: x, y: y}), do: {x, y}
|
||||
defp parse_point(_), do: nil
|
||||
|
||||
# Fallback data for basic testing without WZ exports
|
||||
defp create_fallback_strings do
|
||||
fallback_names = %{
|
||||
# Beginner skills
|
||||
1_000 => "Three Snails",
|
||||
1_001 => "Recovery",
|
||||
1_002 => "Nimble Feet",
|
||||
1_003 => "Monster Rider",
|
||||
1_004 => "Echo of Hero",
|
||||
|
||||
# Warrior 1st job
|
||||
100_000 => "Power Strike",
|
||||
100_001 => "Slash Blast",
|
||||
100_002 => "Iron Body",
|
||||
100_003 => "Iron Body",
|
||||
100_004 => "Power Strike",
|
||||
100_005 => "Slash Blast",
|
||||
100_006 => "Iron Body",
|
||||
100_007 => "Power Strike",
|
||||
100_008 => "Slash Blast",
|
||||
100_009 => "Iron Body",
|
||||
100_010 => "Power Strike",
|
||||
100_100 => "Power Strike",
|
||||
100_101 => "Slash Blast",
|
||||
100_102 => "Iron Body",
|
||||
|
||||
# Magician 1st job
|
||||
200_000 => "Magic Claw",
|
||||
200_001 => "Teleport",
|
||||
200_002 => "Magic Guard",
|
||||
200_003 => "Magic Armor",
|
||||
200_004 => "Energy Bolt",
|
||||
200_005 => "Magic Claw",
|
||||
200_006 => "Teleport",
|
||||
200_007 => "Magic Guard",
|
||||
200_008 => "Magic Armor",
|
||||
200_009 => "Energy Bolt",
|
||||
200_100 => "Magic Claw",
|
||||
200_101 => "Teleport",
|
||||
200_102 => "Magic Guard",
|
||||
200_103 => "Magic Armor",
|
||||
200_104 => "Energy Bolt",
|
||||
|
||||
# Bowman 1st job
|
||||
300_000 => "Arrow Blow",
|
||||
300_001 => "Double Shot",
|
||||
300_002 => "Critical Shot",
|
||||
300_003 => "The Eye of Amazon",
|
||||
300_004 => "Focus",
|
||||
300_100 => "Arrow Blow",
|
||||
300_101 => "Double Shot",
|
||||
300_102 => "Critical Shot",
|
||||
300_103 => "The Eye of Amazon",
|
||||
300_104 => "Focus",
|
||||
|
||||
# Thief 1st job
|
||||
400_000 => "Lucky Seven",
|
||||
400_001 => "Double Stab",
|
||||
400_002 => "Disorder",
|
||||
400_003 => "Dark Sight",
|
||||
400_004 => "Lucky Seven",
|
||||
400_005 => "Double Stab",
|
||||
400_100 => "Lucky Seven",
|
||||
400_101 => "Double Stab",
|
||||
400_102 => "Disorder",
|
||||
400_103 => "Dark Sight",
|
||||
400_104 => "Lucky Seven",
|
||||
400_105 => "Double Stab",
|
||||
|
||||
# Pirate 1st job
|
||||
500_000 => "Somersault Kick",
|
||||
500_001 => "Double Fire",
|
||||
500_002 => "Dash",
|
||||
500_003 => "Shadow Heart",
|
||||
500_004 => "Somersault Kick",
|
||||
500_005 => "Double Fire",
|
||||
500_100 => "Somersault Kick",
|
||||
500_101 => "Double Fire",
|
||||
500_102 => "Dash",
|
||||
500_103 => "Shadow Heart",
|
||||
500_104 => "Somersault Kick",
|
||||
500_105 => "Double Fire",
|
||||
|
||||
# GM skills
|
||||
9_001_000 => "Haste",
|
||||
9_001_001 => "Dragon Roar",
|
||||
9_001_002 => "Holy Symbol",
|
||||
9_001_003 => "Heal",
|
||||
9_001_004 => "Hide",
|
||||
9_001_005 => "Resurrection",
|
||||
9_001_006 => "Hyper Body",
|
||||
9_001_007 => "Holy Shield",
|
||||
9_001_008 => "Holy Shield",
|
||||
|
||||
# 4th job common
|
||||
1_122_004 => "Hero's Will",
|
||||
1_222_004 => "Hero's Will",
|
||||
1_322_004 => "Hero's Will",
|
||||
2_122_004 => "Hero's Will",
|
||||
2_222_004 => "Hero's Will",
|
||||
2_322_004 => "Hero's Will",
|
||||
3_122_004 => "Hero's Will",
|
||||
4_122_004 => "Hero's Will",
|
||||
4_222_004 => "Hero's Will",
|
||||
5_122_004 => "Hero's Will",
|
||||
5_222_004 => "Hero's Will",
|
||||
|
||||
# Maple Warrior (all 4th jobs)
|
||||
1_121_000 => "Maple Warrior",
|
||||
1_221_000 => "Maple Warrior",
|
||||
1_321_000 => "Maple Warrior",
|
||||
2_121_000 => "Maple Warrior",
|
||||
2_221_000 => "Maple Warrior",
|
||||
2_321_000 => "Maple Warrior",
|
||||
3_121_000 => "Maple Warrior",
|
||||
3_221_000 => "Maple Warrior",
|
||||
4_121_000 => "Maple Warrior",
|
||||
4_221_000 => "Maple Warrior",
|
||||
5_121_000 => "Maple Warrior",
|
||||
5_221_000 => "Maple Warrior"
|
||||
}
|
||||
|
||||
Enum.each(fallback_names, fn {skill_id, name} ->
|
||||
:ets.insert(@skill_names, {skill_id, name})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_skills do
|
||||
# Create some basic beginner skills as fallback
|
||||
fallback_skills = [
|
||||
%{
|
||||
id: 1_000,
|
||||
name: "Three Snails",
|
||||
element: :physical,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, damage: 150, mp_con: 10, mob_count: 1, x: 15},
|
||||
%{level: 2, damage: 200, mp_con: 15, mob_count: 1, x: 30},
|
||||
%{level: 3, damage: 250, mp_con: 20, mob_count: 1, x: 45}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_001,
|
||||
name: "Recovery",
|
||||
element: :neutral,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, duration: 30000, hp: 10, interval: 2000, x: 10},
|
||||
%{level: 2, duration: 30000, hp: 20, interval: 1900, x: 20},
|
||||
%{level: 3, duration: 30000, hp: 30, interval: 1800, x: 30}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_002,
|
||||
name: "Nimble Feet",
|
||||
element: :neutral,
|
||||
max_level: 3,
|
||||
true_max: 3,
|
||||
effects: [
|
||||
%{level: 1, duration: 4000, speed: 10, x: 10},
|
||||
%{level: 2, duration: 8000, speed: 15, x: 15},
|
||||
%{level: 3, duration: 12000, speed: 20, x: 20}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 1_004,
|
||||
name: "Echo of Hero",
|
||||
element: :neutral,
|
||||
max_level: 1,
|
||||
true_max: 1,
|
||||
effects: [
|
||||
%{level: 1, duration: 1200000, watk: 4, wdef: 4, matk: 4, mdef: 4, x: 4}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 100_000,
|
||||
name: "Power Strike",
|
||||
element: :physical,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 145, mp_con: 8, mob_count: 1, attack_count: 1},
|
||||
%{level: 10, damage: 190, mp_con: 16, mob_count: 1, attack_count: 1},
|
||||
%{level: 20, damage: 245, mp_con: 24, mob_count: 1, attack_count: 1}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 100_001,
|
||||
name: "Slash Blast",
|
||||
element: :physical,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 85, mp_con: 8, mob_count: 3, attack_count: 1},
|
||||
%{level: 10, damage: 115, mp_con: 16, mob_count: 4, attack_count: 1},
|
||||
%{level: 20, damage: 150, mp_con: 24, mob_count: 6, attack_count: 1}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_000,
|
||||
name: "Magic Claw",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
magic: true,
|
||||
skill_type: 1,
|
||||
effects: [
|
||||
%{level: 1, damage: 132, mp_con: 12, mob_count: 2, attack_count: 1, x: 22},
|
||||
%{level: 10, damage: 156, mp_con: 24, mob_count: 2, attack_count: 1, x: 26},
|
||||
%{level: 20, damage: 182, mp_con: 36, mob_count: 2, attack_count: 1, x: 30}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_001,
|
||||
name: "Teleport",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 2,
|
||||
effects: [
|
||||
%{level: 1, mp_con: 40, x: 70},
|
||||
%{level: 10, mp_con: 35, x: 115},
|
||||
%{level: 20, mp_con: 30, x: 160}
|
||||
]
|
||||
},
|
||||
%{
|
||||
id: 200_002,
|
||||
name: "Magic Guard",
|
||||
element: :neutral,
|
||||
max_level: 20,
|
||||
true_max: 20,
|
||||
skill_type: 2,
|
||||
effects: [
|
||||
%{level: 1, x: 15},
|
||||
%{level: 10, x: 42},
|
||||
%{level: 20, x: 70}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(fallback_skills, fn skill_data ->
|
||||
skill = build_skill(skill_data)
|
||||
:ets.insert(@skill_cache, {skill.id, skill})
|
||||
|
||||
job_id = div(skill.id, 10000)
|
||||
|
||||
existing =
|
||||
case :ets.lookup(@skills_by_job, job_id) do
|
||||
[{^job_id, list}] -> list
|
||||
[] -> []
|
||||
end
|
||||
|
||||
:ets.insert(@skills_by_job, {job_id, [skill.id | existing]})
|
||||
end)
|
||||
end
|
||||
end
|
||||
741
lib/odinsea/game/stat_effect.ex
Normal file
741
lib/odinsea/game/stat_effect.ex
Normal file
@@ -0,0 +1,741 @@
|
||||
defmodule Odinsea.Game.StatEffect do
|
||||
@moduledoc """
|
||||
StatEffect struct for skill and item effects.
|
||||
|
||||
Ported from Java: server/MapleStatEffect.java
|
||||
|
||||
StatEffects define what happens when a skill or item is used:
|
||||
- Stat changes (WATK, WDEF, MATK, MDEF, etc.)
|
||||
- HP/MP changes
|
||||
- Buffs and debuffs
|
||||
- Monster status effects
|
||||
- Cooldowns and durations
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.MonsterStatus
|
||||
|
||||
defstruct [
|
||||
# Basic info
|
||||
:source_id,
|
||||
:level,
|
||||
:is_skill,
|
||||
:duration,
|
||||
:over_time,
|
||||
|
||||
# HP/MP
|
||||
:hp,
|
||||
:mp,
|
||||
:hp_r,
|
||||
:mp_r,
|
||||
:mhp_r,
|
||||
:mmp_r,
|
||||
|
||||
# Combat stats
|
||||
:watk,
|
||||
:wdef,
|
||||
:matk,
|
||||
:mdef,
|
||||
:acc,
|
||||
:avoid,
|
||||
:hands,
|
||||
:speed,
|
||||
:jump,
|
||||
:mastery,
|
||||
|
||||
# Damage modifiers
|
||||
:damage,
|
||||
:pdd_r,
|
||||
:mdd_r,
|
||||
:dam_r,
|
||||
:bd_r,
|
||||
:ignore_mob,
|
||||
:critical_damage_min,
|
||||
:critical_damage_max,
|
||||
:asr_r,
|
||||
:er,
|
||||
|
||||
# Skill-specific
|
||||
:prop,
|
||||
:mob_count,
|
||||
:attack_count,
|
||||
:bullet_count,
|
||||
:cooldown,
|
||||
:interval,
|
||||
|
||||
# MP/HP consumption
|
||||
:mp_con,
|
||||
:hp_con,
|
||||
:force_con,
|
||||
:mp_con_reduce,
|
||||
|
||||
# Movement
|
||||
:move_to,
|
||||
|
||||
# Morph
|
||||
:morph_id,
|
||||
|
||||
# Summon
|
||||
:summon_movement_type,
|
||||
|
||||
# DoT (Damage over Time)
|
||||
:dot,
|
||||
:dot_time,
|
||||
|
||||
# Special effects
|
||||
:thaw,
|
||||
:self_destruction,
|
||||
:pvp_damage,
|
||||
:inc_pvp_damage,
|
||||
|
||||
# Independent stats (angel buffs)
|
||||
:indie_pad,
|
||||
:indie_mad,
|
||||
:indie_mhp,
|
||||
:indie_mmp,
|
||||
:indie_speed,
|
||||
:indie_jump,
|
||||
:indie_acc,
|
||||
:indie_eva,
|
||||
:indie_pdd,
|
||||
:indie_mdd,
|
||||
:indie_all_stat,
|
||||
|
||||
# Base stats
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:str_x,
|
||||
:dex_x,
|
||||
:int_x,
|
||||
:luk_x,
|
||||
|
||||
# Enhanced stats
|
||||
:ehp,
|
||||
:emp,
|
||||
:ewatk,
|
||||
:ewdef,
|
||||
:emdef,
|
||||
|
||||
# Misc
|
||||
:pad_x,
|
||||
:mad_x,
|
||||
:meso_r,
|
||||
:exp_r,
|
||||
|
||||
# Item consumption
|
||||
:item_con,
|
||||
:item_con_no,
|
||||
:bullet_consume,
|
||||
:money_con,
|
||||
|
||||
# Position/Range
|
||||
:lt,
|
||||
:rb,
|
||||
:range,
|
||||
|
||||
# Buff stats (map of CharacterTemporaryStat => value)
|
||||
:stat_ups,
|
||||
|
||||
# Monster status effects
|
||||
:monster_status,
|
||||
|
||||
# Cure debuffs
|
||||
:cure_debuffs,
|
||||
|
||||
# Other
|
||||
:expinc,
|
||||
:exp_buff,
|
||||
:itemup,
|
||||
:mesoup,
|
||||
:cashup,
|
||||
:berserk,
|
||||
:berserk2,
|
||||
:booster,
|
||||
:illusion,
|
||||
:life_id,
|
||||
:inflation,
|
||||
:imhp,
|
||||
:immp,
|
||||
:use_level,
|
||||
:char_color,
|
||||
:recipe,
|
||||
:recipe_use_count,
|
||||
:recipe_valid_day,
|
||||
:req_skill_level,
|
||||
:slot_count,
|
||||
:preventslip,
|
||||
:immortal,
|
||||
:type,
|
||||
:bs,
|
||||
:cr,
|
||||
:t,
|
||||
:u,
|
||||
:v,
|
||||
:w,
|
||||
:x,
|
||||
:y,
|
||||
:z,
|
||||
:mob_skill,
|
||||
:mob_skill_level,
|
||||
:familiar_target,
|
||||
:fatigue_change,
|
||||
:available_maps,
|
||||
:reward_meso,
|
||||
:reward_items,
|
||||
:pets_can_consume,
|
||||
:familiars,
|
||||
:random_pickup,
|
||||
:traits,
|
||||
:party_buff
|
||||
]
|
||||
|
||||
@type point :: {integer(), integer()}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
source_id: integer(),
|
||||
level: integer(),
|
||||
is_skill: boolean(),
|
||||
duration: integer(),
|
||||
over_time: boolean(),
|
||||
hp: integer(),
|
||||
mp: integer(),
|
||||
hp_r: float(),
|
||||
mp_r: float(),
|
||||
mhp_r: integer(),
|
||||
mmp_r: integer(),
|
||||
watk: integer(),
|
||||
wdef: integer(),
|
||||
matk: integer(),
|
||||
mdef: integer(),
|
||||
acc: integer(),
|
||||
avoid: integer(),
|
||||
hands: integer(),
|
||||
speed: integer(),
|
||||
jump: integer(),
|
||||
mastery: integer(),
|
||||
damage: integer(),
|
||||
pdd_r: integer(),
|
||||
mdd_r: integer(),
|
||||
dam_r: integer(),
|
||||
bd_r: integer(),
|
||||
ignore_mob: integer(),
|
||||
critical_damage_min: integer(),
|
||||
critical_damage_max: integer(),
|
||||
asr_r: integer(),
|
||||
er: integer(),
|
||||
prop: integer(),
|
||||
mob_count: integer(),
|
||||
attack_count: integer(),
|
||||
bullet_count: integer(),
|
||||
cooldown: integer(),
|
||||
interval: integer(),
|
||||
mp_con: integer(),
|
||||
hp_con: integer(),
|
||||
force_con: integer(),
|
||||
mp_con_reduce: integer(),
|
||||
move_to: integer(),
|
||||
morph_id: integer(),
|
||||
summon_movement_type: atom() | nil,
|
||||
dot: integer(),
|
||||
dot_time: integer(),
|
||||
thaw: integer(),
|
||||
self_destruction: integer(),
|
||||
pvp_damage: integer(),
|
||||
inc_pvp_damage: integer(),
|
||||
indie_pad: integer(),
|
||||
indie_mad: integer(),
|
||||
indie_mhp: integer(),
|
||||
indie_mmp: integer(),
|
||||
indie_speed: integer(),
|
||||
indie_jump: integer(),
|
||||
indie_acc: integer(),
|
||||
indie_eva: integer(),
|
||||
indie_pdd: integer(),
|
||||
indie_mdd: integer(),
|
||||
indie_all_stat: integer(),
|
||||
str: integer(),
|
||||
dex: integer(),
|
||||
int: integer(),
|
||||
luk: integer(),
|
||||
str_x: integer(),
|
||||
dex_x: integer(),
|
||||
int_x: integer(),
|
||||
luk_x: integer(),
|
||||
ehp: integer(),
|
||||
emp: integer(),
|
||||
ewatk: integer(),
|
||||
ewdef: integer(),
|
||||
emdef: integer(),
|
||||
pad_x: integer(),
|
||||
mad_x: integer(),
|
||||
meso_r: integer(),
|
||||
exp_r: integer(),
|
||||
item_con: integer(),
|
||||
item_con_no: integer(),
|
||||
bullet_consume: integer(),
|
||||
money_con: integer(),
|
||||
lt: point() | nil,
|
||||
rb: point() | nil,
|
||||
range: integer(),
|
||||
stat_ups: map(),
|
||||
monster_status: map(),
|
||||
cure_debuffs: [atom()],
|
||||
expinc: integer(),
|
||||
exp_buff: integer(),
|
||||
itemup: integer(),
|
||||
mesoup: integer(),
|
||||
cashup: integer(),
|
||||
berserk: integer(),
|
||||
berserk2: integer(),
|
||||
booster: integer(),
|
||||
illusion: integer(),
|
||||
life_id: integer(),
|
||||
inflation: integer(),
|
||||
imhp: integer(),
|
||||
immp: integer(),
|
||||
use_level: integer(),
|
||||
char_color: integer(),
|
||||
recipe: integer(),
|
||||
recipe_use_count: integer(),
|
||||
recipe_valid_day: integer(),
|
||||
req_skill_level: integer(),
|
||||
slot_count: integer(),
|
||||
preventslip: integer(),
|
||||
immortal: integer(),
|
||||
type: integer(),
|
||||
bs: integer(),
|
||||
cr: integer(),
|
||||
t: integer(),
|
||||
u: integer(),
|
||||
v: integer(),
|
||||
w: integer(),
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
z: integer(),
|
||||
mob_skill: integer(),
|
||||
mob_skill_level: integer(),
|
||||
familiar_target: integer(),
|
||||
fatigue_change: integer(),
|
||||
available_maps: [{integer(), integer()}],
|
||||
reward_meso: integer(),
|
||||
reward_items: [{integer(), integer(), integer()}],
|
||||
pets_can_consume: [integer()],
|
||||
familiars: [integer()],
|
||||
random_pickup: [integer()],
|
||||
traits: map(),
|
||||
party_buff: boolean()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new StatEffect with default values.
|
||||
"""
|
||||
@spec new(integer(), integer(), boolean()) :: t()
|
||||
def new(source_id, level, is_skill) do
|
||||
%__MODULE__{
|
||||
source_id: source_id,
|
||||
level: level,
|
||||
is_skill: is_skill,
|
||||
duration: -1,
|
||||
over_time: false,
|
||||
hp: 0,
|
||||
mp: 0,
|
||||
hp_r: 0.0,
|
||||
mp_r: 0.0,
|
||||
mhp_r: 0,
|
||||
mmp_r: 0,
|
||||
watk: 0,
|
||||
wdef: 0,
|
||||
matk: 0,
|
||||
mdef: 0,
|
||||
acc: 0,
|
||||
avoid: 0,
|
||||
hands: 0,
|
||||
speed: 0,
|
||||
jump: 0,
|
||||
mastery: 0,
|
||||
damage: 100,
|
||||
pdd_r: 0,
|
||||
mdd_r: 0,
|
||||
dam_r: 0,
|
||||
bd_r: 0,
|
||||
ignore_mob: 0,
|
||||
critical_damage_min: 0,
|
||||
critical_damage_max: 0,
|
||||
asr_r: 0,
|
||||
er: 0,
|
||||
prop: 100,
|
||||
mob_count: 1,
|
||||
attack_count: 1,
|
||||
bullet_count: 1,
|
||||
cooldown: 0,
|
||||
interval: 0,
|
||||
mp_con: 0,
|
||||
hp_con: 0,
|
||||
force_con: 0,
|
||||
mp_con_reduce: 0,
|
||||
move_to: -1,
|
||||
morph_id: 0,
|
||||
summon_movement_type: nil,
|
||||
dot: 0,
|
||||
dot_time: 0,
|
||||
thaw: 0,
|
||||
self_destruction: 0,
|
||||
pvp_damage: 0,
|
||||
inc_pvp_damage: 0,
|
||||
indie_pad: 0,
|
||||
indie_mad: 0,
|
||||
indie_mhp: 0,
|
||||
indie_mmp: 0,
|
||||
indie_speed: 0,
|
||||
indie_jump: 0,
|
||||
indie_acc: 0,
|
||||
indie_eva: 0,
|
||||
indie_pdd: 0,
|
||||
indie_mdd: 0,
|
||||
indie_all_stat: 0,
|
||||
str: 0,
|
||||
dex: 0,
|
||||
int: 0,
|
||||
luk: 0,
|
||||
str_x: 0,
|
||||
dex_x: 0,
|
||||
int_x: 0,
|
||||
luk_x: 0,
|
||||
ehp: 0,
|
||||
emp: 0,
|
||||
ewatk: 0,
|
||||
ewdef: 0,
|
||||
emdef: 0,
|
||||
pad_x: 0,
|
||||
mad_x: 0,
|
||||
meso_r: 0,
|
||||
exp_r: 0,
|
||||
item_con: 0,
|
||||
item_con_no: 0,
|
||||
bullet_consume: 0,
|
||||
money_con: 0,
|
||||
lt: nil,
|
||||
rb: nil,
|
||||
range: 0,
|
||||
stat_ups: %{},
|
||||
monster_status: %{},
|
||||
cure_debuffs: [],
|
||||
expinc: 0,
|
||||
exp_buff: 0,
|
||||
itemup: 0,
|
||||
mesoup: 0,
|
||||
cashup: 0,
|
||||
berserk: 0,
|
||||
berserk2: 0,
|
||||
booster: 0,
|
||||
illusion: 0,
|
||||
life_id: 0,
|
||||
inflation: 0,
|
||||
imhp: 0,
|
||||
immp: 0,
|
||||
use_level: 0,
|
||||
char_color: 0,
|
||||
recipe: 0,
|
||||
recipe_use_count: 0,
|
||||
recipe_valid_day: 0,
|
||||
req_skill_level: 0,
|
||||
slot_count: 0,
|
||||
preventslip: 0,
|
||||
immortal: 0,
|
||||
type: 0,
|
||||
bs: 0,
|
||||
cr: 0,
|
||||
t: 0,
|
||||
u: 0,
|
||||
v: 0,
|
||||
w: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
mob_skill: 0,
|
||||
mob_skill_level: 0,
|
||||
familiar_target: 0,
|
||||
fatigue_change: 0,
|
||||
available_maps: [],
|
||||
reward_meso: 0,
|
||||
reward_items: [],
|
||||
pets_can_consume: [],
|
||||
familiars: [],
|
||||
random_pickup: [],
|
||||
traits: %{},
|
||||
party_buff: true
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect has a cooldown.
|
||||
"""
|
||||
@spec has_cooldown?(t()) :: boolean()
|
||||
def has_cooldown?(effect) do
|
||||
effect.cooldown > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a heal effect.
|
||||
"""
|
||||
@spec is_heal?(t()) :: boolean()
|
||||
def is_heal?(effect) do
|
||||
effect.source_id in [2_301_002, 9_101_002, 9_101_004]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a resurrection effect.
|
||||
"""
|
||||
@spec is_resurrection?(t()) :: boolean()
|
||||
def is_resurrection?(effect) do
|
||||
effect.source_id == 2_321_006
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a dispel effect.
|
||||
"""
|
||||
@spec is_dispel?(t()) :: boolean()
|
||||
def is_dispel?(effect) do
|
||||
effect.source_id == 2_311_001
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a hero's will effect.
|
||||
"""
|
||||
@spec is_hero_will?(t()) :: boolean()
|
||||
def is_hero_will?(effect) do
|
||||
effect.source_id in [1_121_004, 1_221_004, 1_321_004, 2_122_004, 2_222_004,
|
||||
2_322_004, 3_122_004, 4_122_004, 4_222_004, 5_122_004,
|
||||
5_222_004, 2_217_004, 4_341_000, 3_221_007, 3_321_007]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a time leap effect.
|
||||
"""
|
||||
@spec is_time_leap?(t()) :: boolean()
|
||||
def is_time_leap?(effect) do
|
||||
effect.source_id == 5_121_010
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a mist effect.
|
||||
"""
|
||||
@spec is_mist?(t()) :: boolean()
|
||||
def is_mist?(effect) do
|
||||
effect.source_id in [2_111_003, 2_211_003, 1_211_005]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a magic door effect.
|
||||
"""
|
||||
@spec is_magic_door?(t()) :: boolean()
|
||||
def is_magic_door?(effect) do
|
||||
effect.source_id == 2_311_002
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a poison effect.
|
||||
"""
|
||||
@spec is_poison?(t()) :: boolean()
|
||||
def is_poison?(effect) do
|
||||
effect.dot > 0 and effect.dot_time > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a morph effect.
|
||||
"""
|
||||
@spec is_morph?(t()) :: boolean()
|
||||
def is_morph?(effect) do
|
||||
effect.morph_id > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a final attack effect.
|
||||
"""
|
||||
@spec is_final_attack?(t()) :: boolean()
|
||||
def is_final_attack?(effect) do
|
||||
effect.source_id in [1_100_002, 1_200_002, 1_300_002, 3_100_001, 3_200_001,
|
||||
1_110_002, 1_310_002, 2_111_007, 2_221_007, 2_311_007,
|
||||
3_211_010, 3_310_009, 2_215_004, 2_218_004, 1_120_013,
|
||||
3_120_008, 2_310_006, 2_312_012]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is an energy charge effect.
|
||||
"""
|
||||
@spec is_energy_charge?(t()) :: boolean()
|
||||
def is_energy_charge?(effect) do
|
||||
effect.source_id in [5_110_001, 1_510_004]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect makes the player invisible.
|
||||
"""
|
||||
@spec is_hide?(t()) :: boolean()
|
||||
def is_hide?(effect) do
|
||||
effect.source_id in [9_101_004, 9_001_004, 4_330_001]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a shadow partner effect.
|
||||
"""
|
||||
@spec is_shadow_partner?(t()) :: boolean()
|
||||
def is_shadow_partner?(effect) do
|
||||
effect.source_id in [4_111_002, 1_411_000, 4_331_002, 4_211_008]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a combo recharge effect.
|
||||
"""
|
||||
@spec is_combo_recharge?(t()) :: boolean()
|
||||
def is_combo_recharge?(effect) do
|
||||
effect.source_id == 2_111_009
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a spirit claw effect.
|
||||
"""
|
||||
@spec is_spirit_claw?(t()) :: boolean()
|
||||
def is_spirit_claw?(effect) do
|
||||
effect.source_id == 4_121_006
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a Mech door effect.
|
||||
"""
|
||||
@spec is_mech_door?(t()) :: boolean()
|
||||
def is_mech_door?(effect) do
|
||||
effect.source_id == 3_511_005
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a mist eruption effect.
|
||||
"""
|
||||
@spec is_mist_eruption?(t()) :: boolean()
|
||||
def is_mist_eruption?(effect) do
|
||||
effect.source_id == 2_121_005
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this effect affects monsters.
|
||||
"""
|
||||
@spec is_monster_buff?(t()) :: boolean()
|
||||
def is_monster_buff?(effect) do
|
||||
count = stat_size(effect.monster_status)
|
||||
count > 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a party buff.
|
||||
"""
|
||||
@spec is_party_buff?(t()) :: boolean()
|
||||
def is_party_buff?(effect) do
|
||||
effect.party_buff
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the bounding box for this effect based on position.
|
||||
"""
|
||||
@spec calculate_bounding_box(t(), {integer(), integer()}, boolean()) ::
|
||||
{{integer(), integer()}, {integer(), integer()}} | nil
|
||||
def calculate_bounding_box(effect, {x, y}, facing_left) do
|
||||
case {effect.lt, effect.rb} do
|
||||
{nil, nil} ->
|
||||
# Default bounding box
|
||||
width = 200 + effect.range
|
||||
height = 100 + effect.range
|
||||
|
||||
if facing_left do
|
||||
{{x - width, y - div(height, 2)}, {x, y + div(height, 2)}}
|
||||
else
|
||||
{{x, y - div(height, 2)}, {x + width, y + div(height, 2)}}
|
||||
end
|
||||
|
||||
{{lt_x, lt_y}, {rb_x, rb_y}} ->
|
||||
if facing_left do
|
||||
{{x + lt_x - effect.range, y + lt_y}, {x + rb_x, y + rb_y}}
|
||||
else
|
||||
{{x - rb_x + effect.range, y + lt_y}, {x - lt_x, y + rb_y}}
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Makes a chance result check based on the effect's prop value.
|
||||
"""
|
||||
@spec make_chance_result?(t()) :: boolean()
|
||||
def make_chance_result?(effect) do
|
||||
effect.prop >= 100 or :rand.uniform(100) < effect.prop
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the summon movement type if this effect summons something.
|
||||
"""
|
||||
@spec get_summon_movement_type(t()) :: atom() | nil
|
||||
def get_summon_movement_type(effect) do
|
||||
effect.summon_movement_type
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total stat change for a specific stat.
|
||||
"""
|
||||
@spec get_stat_change(t(), atom()) :: integer()
|
||||
def get_stat_change(effect, stat) do
|
||||
case stat do
|
||||
:str -> effect.str
|
||||
:dex -> effect.dex
|
||||
:int -> effect.int
|
||||
:luk -> effect.luk
|
||||
:max_hp -> effect.mhp_r
|
||||
:max_mp -> effect.mmp_r
|
||||
:watk -> effect.watk
|
||||
:wdef -> effect.wdef
|
||||
:matk -> effect.matk
|
||||
:mdef -> effect.mdef
|
||||
:acc -> effect.acc
|
||||
:avoid -> effect.avoid
|
||||
:speed -> effect.speed
|
||||
:jump -> effect.jump
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies this effect to HP calculation.
|
||||
Returns the HP change (can be negative).
|
||||
"""
|
||||
@spec calc_hp_change(t(), integer(), boolean()) :: integer()
|
||||
def calc_hp_change(effect, max_hp, _primary) do
|
||||
hp_change = effect.hp
|
||||
|
||||
# Apply HP% recovery/consumption
|
||||
hp_change = hp_change + trunc(max_hp * effect.hp_r)
|
||||
|
||||
# Cap recovery to max HP
|
||||
min(hp_change, max_hp)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies this effect to MP calculation.
|
||||
Returns the MP change (can be negative).
|
||||
"""
|
||||
@spec calc_mp_change(t(), integer(), boolean()) :: integer()
|
||||
def calc_mp_change(effect, max_mp, _primary) do
|
||||
mp_change = effect.mp
|
||||
|
||||
# Apply MP% recovery/consumption
|
||||
mp_change = mp_change + trunc(max_mp * effect.mp_r)
|
||||
|
||||
# Cap recovery to max MP
|
||||
min(mp_change, max_mp)
|
||||
end
|
||||
|
||||
# Helper for map size
|
||||
defp stat_size(nil), do: 0
|
||||
defp stat_size(map) when is_map(map), do: stat_size(Map.keys(map))
|
||||
defp stat_size(list) when is_list(list), do: length(list)
|
||||
end
|
||||
411
lib/odinsea/game/timer.ex
Normal file
411
lib/odinsea/game/timer.ex
Normal file
@@ -0,0 +1,411 @@
|
||||
defmodule Odinsea.Game.Timer do
|
||||
@moduledoc """
|
||||
Timer system for scheduling game events.
|
||||
Ported from Java `server.Timer`.
|
||||
|
||||
Provides multiple timer types for different purposes:
|
||||
- WorldTimer - Global world events
|
||||
- MapTimer - Map-specific events
|
||||
- BuffTimer - Character buffs
|
||||
- EventTimer - Game events
|
||||
- CloneTimer - Character clones
|
||||
- EtcTimer - Miscellaneous
|
||||
- CheatTimer - Anti-cheat monitoring
|
||||
- PingTimer - Connection keep-alive
|
||||
- RedisTimer - Redis updates
|
||||
- EMTimer - Event manager
|
||||
- GlobalTimer - Global scheduled tasks
|
||||
|
||||
Each timer is a GenServer that manages scheduled tasks using
|
||||
`Process.send_after` for efficient Erlang VM scheduling.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Task Struct (defined first for use in Base)
|
||||
# ============================================================================
|
||||
|
||||
defmodule Task do
|
||||
@moduledoc """
|
||||
Represents a scheduled task.
|
||||
|
||||
Fields:
|
||||
- id: Unique task identifier
|
||||
- type: :one_shot or :recurring
|
||||
- fun: The function to execute (arity 0)
|
||||
- repeat_time: For recurring tasks, interval in milliseconds
|
||||
- timer_ref: Reference to the Erlang timer
|
||||
"""
|
||||
defstruct [
|
||||
:id,
|
||||
:type,
|
||||
:fun,
|
||||
:repeat_time,
|
||||
:timer_ref
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: pos_integer(),
|
||||
type: :one_shot | :recurring,
|
||||
fun: function(),
|
||||
repeat_time: non_neg_integer() | nil,
|
||||
timer_ref: reference()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Base Timer Implementation (GenServer) - Must be defined before timer types
|
||||
# ============================================================================
|
||||
|
||||
defmodule Base do
|
||||
@moduledoc """
|
||||
Base implementation for all timer types.
|
||||
Uses GenServer with Process.send_after for scheduling.
|
||||
"""
|
||||
|
||||
defmacro __using__(opts) do
|
||||
timer_name = Keyword.fetch!(opts, :name)
|
||||
|
||||
quote do
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Timer.Task
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the timer GenServer.
|
||||
"""
|
||||
def start_link(_opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a recurring task that executes at fixed intervals.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `repeat_time`: Interval in milliseconds between executions
|
||||
- `delay`: Initial delay in milliseconds before first execution (default: 0)
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def register(fun, repeat_time, delay \\ 0) when is_function(fun, 0) and is_integer(repeat_time) and repeat_time > 0 do
|
||||
GenServer.call(__MODULE__, {:register, fun, repeat_time, delay})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task to execute after a delay.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `delay`: Delay in milliseconds before execution
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def schedule(fun, delay) when is_function(fun, 0) and is_integer(delay) and delay >= 0 do
|
||||
GenServer.call(__MODULE__, {:schedule, fun, delay})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task to execute at a specific timestamp.
|
||||
|
||||
## Parameters
|
||||
- `fun`: Function to execute (arity 0)
|
||||
- `timestamp`: Unix timestamp in milliseconds
|
||||
|
||||
## Returns
|
||||
- `{:ok, task_id}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def schedule_at_timestamp(fun, timestamp) when is_function(fun, 0) and is_integer(timestamp) do
|
||||
delay = timestamp - System.system_time(:millisecond)
|
||||
schedule(fun, max(0, delay))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a scheduled or recurring task.
|
||||
|
||||
## Parameters
|
||||
- `task_id`: The task ID returned from register/schedule
|
||||
|
||||
## Returns
|
||||
- `:ok` on success
|
||||
- `{:error, :not_found}` if task doesn't exist
|
||||
"""
|
||||
def cancel(task_id) do
|
||||
GenServer.call(__MODULE__, {:cancel, task_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the timer and cancels all pending tasks.
|
||||
"""
|
||||
def stop do
|
||||
GenServer.stop(__MODULE__, :normal)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets information about all active tasks.
|
||||
"""
|
||||
def info do
|
||||
GenServer.call(__MODULE__, :info)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Logger.debug("#{__MODULE__} started")
|
||||
{:ok, %{tasks: %{}, next_id: 1}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:register, fun, repeat_time, delay}, _from, state) do
|
||||
task_id = state.next_id
|
||||
|
||||
# Schedule initial execution
|
||||
timer_ref = Process.send_after(self(), {:execute_recurring, task_id}, delay)
|
||||
|
||||
task = %Task{
|
||||
id: task_id,
|
||||
type: :recurring,
|
||||
fun: fun,
|
||||
repeat_time: repeat_time,
|
||||
timer_ref: timer_ref
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| tasks: Map.put(state.tasks, task_id, task),
|
||||
next_id: task_id + 1
|
||||
}
|
||||
|
||||
{:reply, {:ok, task_id}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule, fun, delay}, _from, state) do
|
||||
task_id = state.next_id
|
||||
|
||||
timer_ref = Process.send_after(self(), {:execute_once, task_id}, delay)
|
||||
|
||||
task = %Task{
|
||||
id: task_id,
|
||||
type: :one_shot,
|
||||
fun: fun,
|
||||
timer_ref: timer_ref
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| tasks: Map.put(state.tasks, task_id, task),
|
||||
next_id: task_id + 1
|
||||
}
|
||||
|
||||
{:reply, {:ok, task_id}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel, task_id}, _from, state) do
|
||||
case Map.pop(state.tasks, task_id) do
|
||||
{nil, _} ->
|
||||
{:reply, {:error, :not_found}, state}
|
||||
|
||||
{task, remaining_tasks} ->
|
||||
# Cancel the timer if it hasn't fired yet
|
||||
Process.cancel_timer(task.timer_ref)
|
||||
{:reply, :ok, %{state | tasks: remaining_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:info, _from, state) do
|
||||
info = %{
|
||||
module: __MODULE__,
|
||||
task_count: map_size(state.tasks),
|
||||
tasks: state.tasks
|
||||
}
|
||||
|
||||
{:reply, info, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:execute_once, task_id}, state) do
|
||||
case Map.pop(state.tasks, task_id) do
|
||||
{nil, _} ->
|
||||
# Task was already cancelled
|
||||
{:noreply, state}
|
||||
|
||||
{task, remaining_tasks} ->
|
||||
# Execute the task with error handling
|
||||
execute_task(task)
|
||||
{:noreply, %{state | tasks: remaining_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:execute_recurring, task_id}, state) do
|
||||
case Map.get(state.tasks, task_id) do
|
||||
nil ->
|
||||
# Task was cancelled
|
||||
{:noreply, state}
|
||||
|
||||
task ->
|
||||
# Execute the task with error handling
|
||||
execute_task(task)
|
||||
|
||||
# Reschedule the next execution
|
||||
new_timer_ref = Process.send_after(self(), {:execute_recurring, task_id}, task.repeat_time)
|
||||
|
||||
updated_task = %{task | timer_ref: new_timer_ref}
|
||||
new_tasks = Map.put(state.tasks, task_id, updated_task)
|
||||
|
||||
{:noreply, %{state | tasks: new_tasks}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
# Cancel all pending timers
|
||||
Enum.each(state.tasks, fn {_id, task} ->
|
||||
Process.cancel_timer(task.timer_ref)
|
||||
end)
|
||||
|
||||
Logger.debug("#{__MODULE__} stopped, cancelled #{map_size(state.tasks)} tasks")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp execute_task(task) do
|
||||
try do
|
||||
task.fun.()
|
||||
rescue
|
||||
exception ->
|
||||
Logger.error("#{__MODULE__} task #{task.id} failed: #{Exception.message(exception)}")
|
||||
Logger.debug("#{__MODULE__} task #{task.id} stacktrace: #{Exception.format_stacktrace()}")
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("#{__MODULE__} task #{task.id} crashed: #{kind} - #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Timer Types - Individual GenServer Modules (defined AFTER Base)
|
||||
# ============================================================================
|
||||
|
||||
defmodule WorldTimer do
|
||||
@moduledoc "Timer for global world events."
|
||||
use Odinsea.Game.Timer.Base, name: :world_timer
|
||||
end
|
||||
|
||||
defmodule MapTimer do
|
||||
@moduledoc "Timer for map-specific events."
|
||||
use Odinsea.Game.Timer.Base, name: :map_timer
|
||||
end
|
||||
|
||||
defmodule BuffTimer do
|
||||
@moduledoc "Timer for character buffs."
|
||||
use Odinsea.Game.Timer.Base, name: :buff_timer
|
||||
end
|
||||
|
||||
defmodule EventTimer do
|
||||
@moduledoc "Timer for game events."
|
||||
use Odinsea.Game.Timer.Base, name: :event_timer
|
||||
end
|
||||
|
||||
defmodule CloneTimer do
|
||||
@moduledoc "Timer for character clones."
|
||||
use Odinsea.Game.Timer.Base, name: :clone_timer
|
||||
end
|
||||
|
||||
defmodule EtcTimer do
|
||||
@moduledoc "Timer for miscellaneous tasks."
|
||||
use Odinsea.Game.Timer.Base, name: :etc_timer
|
||||
end
|
||||
|
||||
defmodule CheatTimer do
|
||||
@moduledoc "Timer for anti-cheat monitoring."
|
||||
use Odinsea.Game.Timer.Base, name: :cheat_timer
|
||||
end
|
||||
|
||||
defmodule PingTimer do
|
||||
@moduledoc "Timer for connection keep-alive pings."
|
||||
use Odinsea.Game.Timer.Base, name: :ping_timer
|
||||
end
|
||||
|
||||
defmodule RedisTimer do
|
||||
@moduledoc "Timer for Redis updates."
|
||||
use Odinsea.Game.Timer.Base, name: :redis_timer
|
||||
end
|
||||
|
||||
defmodule EMTimer do
|
||||
@moduledoc "Timer for event manager scheduling."
|
||||
use Odinsea.Game.Timer.Base, name: :em_timer
|
||||
end
|
||||
|
||||
defmodule GlobalTimer do
|
||||
@moduledoc "Timer for global scheduled tasks."
|
||||
use Odinsea.Game.Timer.Base, name: :global_timer
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Functions (Delegating to specific timers)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Schedules a one-shot task on the EtcTimer (for general use).
|
||||
"""
|
||||
def schedule(fun, delay) when is_function(fun, 0) do
|
||||
EtcTimer.schedule(fun, delay)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a recurring task on the EtcTimer (for general use).
|
||||
"""
|
||||
def register(fun, repeat_time, delay \\ 0) when is_function(fun, 0) do
|
||||
EtcTimer.register(fun, repeat_time, delay)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels a task by ID on the EtcTimer.
|
||||
Note: For other timers, use TimerType.cancel(task_id) directly.
|
||||
"""
|
||||
def cancel(task_id) do
|
||||
EtcTimer.cancel(task_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of all timer modules for supervision.
|
||||
"""
|
||||
def all_timer_modules do
|
||||
[
|
||||
WorldTimer,
|
||||
MapTimer,
|
||||
BuffTimer,
|
||||
EventTimer,
|
||||
CloneTimer,
|
||||
EtcTimer,
|
||||
CheatTimer,
|
||||
PingTimer,
|
||||
RedisTimer,
|
||||
EMTimer,
|
||||
GlobalTimer
|
||||
]
|
||||
end
|
||||
end
|
||||
379
lib/odinsea/scripting/behavior.ex
Normal file
379
lib/odinsea/scripting/behavior.ex
Normal file
@@ -0,0 +1,379 @@
|
||||
defmodule Odinsea.Scripting.Behavior do
|
||||
@moduledoc """
|
||||
Behavior module defining callbacks for Odinsea game scripts.
|
||||
|
||||
This behavior is implemented by script modules that are dynamically
|
||||
compiled from script files or created manually as Elixir modules.
|
||||
|
||||
The scripting system supports the following script types:
|
||||
- NPC scripts (conversation/dialogue) - uses `cm` (conversation manager)
|
||||
- Quest scripts - uses `qm` (quest manager)
|
||||
- Portal scripts - uses `pi` (portal interaction)
|
||||
- Reactor scripts - uses `rm` (reactor manager)
|
||||
- Event scripts - uses `em` (event manager)
|
||||
|
||||
## Script Globals
|
||||
|
||||
When scripts are executed, they have access to these globals:
|
||||
|
||||
| Variable | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `cm` | `Odinsea.Scripting.PlayerAPI` | NPC conversation manager |
|
||||
| `qm` | `Odinsea.Scripting.PlayerAPI` | Quest conversation manager |
|
||||
| `pi` | `Odinsea.Scripting.PlayerAPI` | Portal interaction |
|
||||
| `rm` | `Odinsea.Scripting.PlayerAPI` | Reactor actions |
|
||||
| `em` | `Odinsea.Scripting.EventManager` | Event management |
|
||||
| `eim` | `Odinsea.Scripting.EventInstance` | Event instance (for events) |
|
||||
|
||||
## Script Examples
|
||||
|
||||
### NPC Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.NPC.Script_1002001 do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def start(cm) do
|
||||
Odinsea.Scripting.PlayerAPI.send_ok(cm, "Hello! I'm Cody. Nice to meet you!")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def action(cm, _mode, _type, _selection) do
|
||||
Odinsea.Scripting.PlayerAPI.dispose(cm)
|
||||
end
|
||||
end
|
||||
|
||||
### Portal Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.Portal.Script_08_xmas_st do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def enter(pi) do
|
||||
# Portal logic here
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
### Event Script (Elixir module)
|
||||
|
||||
defmodule Odinsea.Scripting.Event.Boats do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
@impl true
|
||||
def init(em) do
|
||||
# Initialize event
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def schedule(em, method_name, delay) do
|
||||
# Handle scheduled callback
|
||||
end
|
||||
|
||||
@impl true
|
||||
def player_entry(eim, player) do
|
||||
# Handle player entering event
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# NPC/Quest Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when an NPC conversation starts.
|
||||
|
||||
## Parameters
|
||||
- `api` - The conversation manager (`cm` for NPC, `qm` for quest)
|
||||
"""
|
||||
@callback start(api :: Odinsea.Scripting.PlayerAPI.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player responds to an NPC dialogue.
|
||||
|
||||
## Parameters
|
||||
- `api` - The conversation manager
|
||||
- `mode` - The mode byte (0 = cancel/end, 1 = next/yes)
|
||||
- `type` - The type byte (usually 0)
|
||||
- `selection` - The player's selection (for menus)
|
||||
"""
|
||||
@callback action(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Quest Script Callbacks (alternative to action for quest scripts)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a quest starts (alternative to `action` for quests).
|
||||
"""
|
||||
@callback quest_start(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a quest ends/completes (alternative to `action` for quests).
|
||||
"""
|
||||
@callback quest_end(api :: Odinsea.Scripting.PlayerAPI.t(),
|
||||
mode :: integer(),
|
||||
type :: integer(),
|
||||
selection :: integer()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Portal Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a player enters a scripted portal.
|
||||
|
||||
## Parameters
|
||||
- `api` - The portal interaction manager
|
||||
|
||||
## Returns
|
||||
- `:ok` - Portal handling successful
|
||||
- `{:error, reason}` - Portal handling failed
|
||||
"""
|
||||
@callback enter(api :: Odinsea.Scripting.PlayerAPI.t()) :: :ok | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Reactor Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when a reactor is activated/hit.
|
||||
|
||||
## Parameters
|
||||
- `api` - The reactor action manager
|
||||
"""
|
||||
@callback act(api :: Odinsea.Scripting.PlayerAPI.t()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Event Script Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Called when an event is initialized (after ChannelServer loads).
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
"""
|
||||
@callback init(em :: Odinsea.Scripting.EventManager.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called to set up an event instance.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager (or eim for some setups)
|
||||
- `args` - Variable arguments depending on event type
|
||||
"""
|
||||
@callback setup(em :: Odinsea.Scripting.EventManager.t(), args :: term()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player enters an event instance.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_entry(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player changes maps within an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
- `map_id` - The new map ID
|
||||
"""
|
||||
@callback changed_map(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t(),
|
||||
map_id :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when an event times out.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback scheduled_timeout(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when all monsters in an event are killed.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback all_monsters_dead(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player dies in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_dead(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player is revived in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
|
||||
## Returns
|
||||
- `true` - Allow revive
|
||||
- `false` - Deny revive
|
||||
"""
|
||||
@callback player_revive(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: boolean()
|
||||
|
||||
@doc """
|
||||
Called when a player disconnects from an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
|
||||
## Returns
|
||||
- `0` - Deregister normally, dispose if no players left
|
||||
- `x > 0` - Deregister, dispose if less than x players
|
||||
- `x < 0` - Deregister, dispose if less than |x| players, boot all if leader
|
||||
"""
|
||||
@callback player_disconnected(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: integer()
|
||||
|
||||
@doc """
|
||||
Called when a monster is killed in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `mob_id` - The monster ID
|
||||
|
||||
## Returns
|
||||
- Points value for this monster kill
|
||||
"""
|
||||
@callback monster_value(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
mob_id :: integer()) :: integer()
|
||||
|
||||
@doc """
|
||||
Called when a player leaves the party in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback left_party(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a party is disbanded in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback disband_party(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a party quest is cleared.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
"""
|
||||
@callback clear_pq(eim :: Odinsea.Scripting.EventInstance.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player is removed from an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback player_exit(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called for scheduled event methods.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
- `method_name` - The name of the method to call
|
||||
- `delay` - The delay in milliseconds
|
||||
"""
|
||||
@callback schedule(em :: Odinsea.Scripting.EventManager.t(),
|
||||
method_name :: String.t(),
|
||||
delay :: integer()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when an event's schedule is cancelled.
|
||||
|
||||
## Parameters
|
||||
- `em` - The event manager
|
||||
"""
|
||||
@callback cancel_schedule(em :: Odinsea.Scripting.EventManager.t()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a carnival party is registered.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `carnival_party` - The carnival party data
|
||||
"""
|
||||
@callback register_carnival_party(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
carnival_party :: term()) :: any()
|
||||
|
||||
@doc """
|
||||
Called when a player loads a map in an event.
|
||||
|
||||
## Parameters
|
||||
- `eim` - The event instance manager
|
||||
- `player` - The player character
|
||||
"""
|
||||
@callback on_map_load(eim :: Odinsea.Scripting.EventInstance.t(),
|
||||
player :: Odinsea.Game.Character.t()) :: any()
|
||||
|
||||
# ============================================================================
|
||||
# Optional Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@optional_callbacks [
|
||||
# NPC/Quest callbacks
|
||||
start: 1,
|
||||
action: 4,
|
||||
quest_start: 4,
|
||||
quest_end: 4,
|
||||
|
||||
# Portal callbacks
|
||||
enter: 1,
|
||||
|
||||
# Reactor callbacks
|
||||
act: 1,
|
||||
|
||||
# Event callbacks
|
||||
init: 1,
|
||||
setup: 2,
|
||||
player_entry: 2,
|
||||
changed_map: 3,
|
||||
scheduled_timeout: 1,
|
||||
all_monsters_dead: 1,
|
||||
player_dead: 2,
|
||||
player_revive: 2,
|
||||
player_disconnected: 2,
|
||||
monster_value: 2,
|
||||
left_party: 2,
|
||||
disband_party: 1,
|
||||
clear_pq: 1,
|
||||
player_exit: 2,
|
||||
schedule: 3,
|
||||
cancel_schedule: 1,
|
||||
register_carnival_party: 2,
|
||||
on_map_load: 2
|
||||
]
|
||||
end
|
||||
787
lib/odinsea/scripting/event_instance.ex
Normal file
787
lib/odinsea/scripting/event_instance.ex
Normal file
@@ -0,0 +1,787 @@
|
||||
defmodule Odinsea.Scripting.EventInstance do
|
||||
@moduledoc """
|
||||
Event Instance Manager for individual event/quest instances.
|
||||
|
||||
Each event instance represents a running copy of a party quest or event,
|
||||
with its own state, players, maps, and timers.
|
||||
|
||||
## State
|
||||
|
||||
Event instances track:
|
||||
- Players registered to the event
|
||||
- Monsters spawned
|
||||
- Kill counts per player
|
||||
- Map instances (cloned maps for PQ)
|
||||
- Custom properties
|
||||
- Event timer
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. EventManager creates instance via `new/3`
|
||||
2. Script `setup/2` is called
|
||||
3. Players register via `register_player/2`
|
||||
4. Event callbacks fire as things happen
|
||||
5. Instance disposes when complete or empty
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
event_manager: Odinsea.Scripting.EventManager.t() | nil,
|
||||
name: String.t(),
|
||||
channel: integer(),
|
||||
players: [integer()],
|
||||
disconnected: [integer()],
|
||||
monsters: [integer()],
|
||||
kill_count: %{integer() => integer()},
|
||||
map_ids: [integer()],
|
||||
is_instanced: [boolean()],
|
||||
properties: %{String.t() => String.t()},
|
||||
timer_started: boolean(),
|
||||
time_started: integer() | nil,
|
||||
event_time: integer() | nil,
|
||||
disposed: boolean()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:event_manager,
|
||||
:name,
|
||||
:channel,
|
||||
players: [],
|
||||
disconnected: [],
|
||||
monsters: [],
|
||||
kill_count: %{},
|
||||
map_ids: [],
|
||||
is_instanced: [],
|
||||
properties: %{},
|
||||
timer_started: false,
|
||||
time_started: nil,
|
||||
event_time: nil,
|
||||
disposed: false
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constructor
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new event instance.
|
||||
|
||||
## Parameters
|
||||
- `event_manager` - Parent EventManager
|
||||
- `name` - Unique instance name
|
||||
- `channel` - Channel number
|
||||
"""
|
||||
@spec new(Odinsea.Scripting.EventManager.t() | nil, String.t(), integer()) :: t()
|
||||
def new(event_manager, name, channel) do
|
||||
%__MODULE__{
|
||||
event_manager: event_manager,
|
||||
name: name,
|
||||
channel: channel
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Player Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a player to this event instance.
|
||||
|
||||
## Parameters
|
||||
- `eim` - Event instance
|
||||
- `player` - Character struct or ID
|
||||
"""
|
||||
@spec register_player(t(), Character.t() | integer()) :: t()
|
||||
def register_player(%{disposed: true} = eim, _), do: eim
|
||||
def register_player(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
# Add to player list
|
||||
players = [char_id | eim.players] |> Enum.uniq()
|
||||
|
||||
%{eim | players: players}
|
||||
|> call_callback(:player_entry, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a player from this event instance.
|
||||
"""
|
||||
@spec unregister_player(t(), Character.t() | integer()) :: t()
|
||||
def unregister_player(%{disposed: true} = eim, _), do: eim
|
||||
def unregister_player(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
players = List.delete(eim.players, char_id)
|
||||
|
||||
%{eim | players: players}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player changing maps.
|
||||
"""
|
||||
@spec changed_map(t(), Character.t() | integer(), integer()) :: t()
|
||||
def changed_map(%{disposed: true} = eim, _, _), do: eim
|
||||
def changed_map(eim, player, map_id) do
|
||||
call_callback(eim, :changed_map, [player, map_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player death.
|
||||
"""
|
||||
@spec player_killed(t(), Character.t() | integer()) :: t()
|
||||
def player_killed(%{disposed: true} = eim, _), do: eim
|
||||
def player_killed(eim, player) do
|
||||
call_callback(eim, :player_dead, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player revive request.
|
||||
|
||||
## Returns
|
||||
- `{allow_revive :: boolean(), updated_eim}`
|
||||
"""
|
||||
@spec revive_player(t(), Character.t() | integer()) :: {boolean(), t()}
|
||||
def revive_player(%{disposed: true} = eim, _), do: {true, eim}
|
||||
def revive_player(eim, player) do
|
||||
result = call_callback_result(eim, :player_revive, [player])
|
||||
allow = if is_boolean(result), do: result, else: true
|
||||
{allow, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player disconnection.
|
||||
|
||||
## Returns
|
||||
- `{:dispose, eim}` - Dispose instance
|
||||
- `{:continue, eim}` - Continue running
|
||||
"""
|
||||
@spec player_disconnected(t(), Character.t() | integer()) :: {:dispose | :continue, t()}
|
||||
def player_disconnected(%{disposed: true} = eim, _), do: {:dispose, eim}
|
||||
def player_disconnected(eim, player) do
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
# Add to disconnected list
|
||||
disconnected = [char_id | eim.disconnected]
|
||||
eim = %{eim | disconnected: disconnected}
|
||||
|
||||
# Remove from players
|
||||
eim = unregister_player(eim, player)
|
||||
|
||||
# Call callback to determine behavior
|
||||
result = call_callback_result(eim, :player_disconnected, [player])
|
||||
|
||||
action = case result do
|
||||
0 ->
|
||||
# Dispose if no players
|
||||
if length(eim.players) == 0, do: :dispose, else: :continue
|
||||
|
||||
x when x > 0 ->
|
||||
# Dispose if less than x players
|
||||
if length(eim.players) < x, do: :dispose, else: :continue
|
||||
|
||||
x when x < 0 ->
|
||||
# Dispose if less than |x| players, or if leader disconnected
|
||||
threshold = abs(x)
|
||||
if length(eim.players) < threshold do
|
||||
:dispose
|
||||
else
|
||||
# TODO: Check if leader disconnected
|
||||
:continue
|
||||
end
|
||||
|
||||
_ ->
|
||||
:continue
|
||||
end
|
||||
|
||||
{action, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes disconnected player ID from tracking.
|
||||
"""
|
||||
@spec remove_disconnected(t(), integer()) :: t()
|
||||
def remove_disconnected(eim, char_id) do
|
||||
disconnected = List.delete(eim.disconnected, char_id)
|
||||
%{eim | disconnected: disconnected}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is disconnected.
|
||||
"""
|
||||
@spec is_disconnected?(t(), integer()) :: boolean()
|
||||
def is_disconnected?(eim, char_id) do
|
||||
char_id in eim.disconnected
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Party Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers an entire party to the event.
|
||||
"""
|
||||
@spec register_party(t(), term(), integer()) :: t()
|
||||
def register_party(%{disposed: true} = eim, _, _), do: eim
|
||||
def register_party(eim, party, map_id) do
|
||||
# TODO: Get party members and register each
|
||||
eim
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a squad (expedition) to the event.
|
||||
"""
|
||||
@spec register_squad(t(), term(), integer(), integer()) :: t()
|
||||
def register_squad(%{disposed: true} = eim, _, _, _), do: eim
|
||||
def register_squad(eim, squad, map_id, quest_id) do
|
||||
# TODO: Register squad members
|
||||
eim
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles player leaving party.
|
||||
"""
|
||||
@spec left_party(t(), Character.t() | integer()) :: t()
|
||||
def left_party(%{disposed: true} = eim, _), do: eim
|
||||
def left_party(eim, player) do
|
||||
call_callback(eim, :left_party, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party disbanding.
|
||||
"""
|
||||
@spec disband_party(t()) :: t()
|
||||
def disband_party(%{disposed: true} = eim), do: eim
|
||||
def disband_party(eim) do
|
||||
call_callback(eim, :disband_party, [])
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Monster Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a monster to this event.
|
||||
"""
|
||||
@spec register_monster(t(), integer()) :: t()
|
||||
def register_monster(%{disposed: true} = eim, _), do: eim
|
||||
def register_monster(eim, mob_id) do
|
||||
monsters = [mob_id | eim.monsters]
|
||||
%{eim | monsters: monsters}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unregisters a monster when killed.
|
||||
"""
|
||||
@spec unregister_monster(t(), integer()) :: t()
|
||||
def unregister_monster(%{disposed: true} = eim, _), do: eim
|
||||
def unregister_monster(eim, mob_id) do
|
||||
monsters = List.delete(eim.monsters, mob_id)
|
||||
eim = %{eim | monsters: monsters}
|
||||
|
||||
# If no monsters left, call allMonstersDead
|
||||
if length(monsters) == 0 do
|
||||
call_callback(eim, :all_monsters_dead, [])
|
||||
else
|
||||
eim
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records monster kill and distributes points.
|
||||
"""
|
||||
@spec monster_killed(t(), Character.t() | integer(), integer()) :: t()
|
||||
def monster_killed(%{disposed: true} = eim, _, _), do: eim
|
||||
def monster_killed(eim, player, mob_id) do
|
||||
# Get monster value from script
|
||||
inc = call_callback_result(eim, :monster_value, [mob_id])
|
||||
inc = if is_integer(inc), do: inc, else: 0
|
||||
|
||||
# Update kill count
|
||||
char_id = case player do
|
||||
%{id: id} -> id
|
||||
id when is_integer(id) -> id
|
||||
end
|
||||
|
||||
current = Map.get(eim.kill_count, char_id, 0)
|
||||
kill_count = Map.put(eim.kill_count, char_id, current + inc)
|
||||
|
||||
%{eim | kill_count: kill_count}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets kill count for a player.
|
||||
"""
|
||||
@spec get_kill_count(t(), integer()) :: integer()
|
||||
def get_kill_count(eim, char_id) do
|
||||
Map.get(eim.kill_count, char_id, 0)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Timer Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts/restarts the event timer.
|
||||
|
||||
## Parameters
|
||||
- `eim` - Event instance
|
||||
- `time_ms` - Time in milliseconds
|
||||
"""
|
||||
@spec start_event_timer(t(), integer()) :: t()
|
||||
def start_event_timer(eim, time_ms) do
|
||||
restart_event_timer(eim, time_ms)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Restarts the event timer.
|
||||
"""
|
||||
@spec restart_event_timer(t(), integer()) :: t()
|
||||
def restart_event_timer(%{disposed: true} = eim, _), do: eim
|
||||
def restart_event_timer(eim, time_ms) do
|
||||
# Send clock packet to all players
|
||||
time_seconds = div(time_ms, 1000)
|
||||
broadcast_packet(eim, {:clock, time_seconds})
|
||||
|
||||
# Schedule timeout
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.schedule(
|
||||
eim,
|
||||
"scheduledTimeout",
|
||||
time_ms
|
||||
)
|
||||
end
|
||||
|
||||
%{eim |
|
||||
timer_started: true,
|
||||
time_started: System.system_time(:millisecond),
|
||||
event_time: time_ms
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the event timer.
|
||||
"""
|
||||
@spec stop_event_timer(t()) :: t()
|
||||
def stop_event_timer(eim) do
|
||||
%{eim |
|
||||
timer_started: false,
|
||||
time_started: nil,
|
||||
event_time: nil
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if timer is started.
|
||||
"""
|
||||
@spec is_timer_started?(t()) :: boolean()
|
||||
def is_timer_started?(eim) do
|
||||
eim.timer_started && eim.time_started != nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets time remaining in milliseconds.
|
||||
"""
|
||||
@spec get_time_left(t()) :: integer()
|
||||
def get_time_left(eim) do
|
||||
if is_timer_started?(eim) do
|
||||
elapsed = System.system_time(:millisecond) - eim.time_started
|
||||
max(0, eim.event_time - elapsed)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a custom method callback.
|
||||
"""
|
||||
@spec schedule(t(), String.t(), integer()) :: reference()
|
||||
def schedule(eim, method_name, delay_ms) do
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.schedule(eim, method_name, delay_ms)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Map Instance Management
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates an instanced map (clone for PQ).
|
||||
|
||||
## Returns
|
||||
- `{map_instance_id, updated_eim}`
|
||||
"""
|
||||
@spec create_instance_map(t(), integer()) :: {integer(), t()}
|
||||
def create_instance_map(%{disposed: true} = eim, _), do: {0, eim}
|
||||
def create_instance_map(eim, map_id) do
|
||||
assigned_id = get_new_instance_map_id()
|
||||
|
||||
# TODO: Create actual map instance
|
||||
# For now, just track the ID
|
||||
eim = %{eim |
|
||||
map_ids: [assigned_id | eim.map_ids],
|
||||
is_instanced: [true | eim.is_instanced]
|
||||
}
|
||||
|
||||
{assigned_id, eim}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an instanced map with simplified settings.
|
||||
"""
|
||||
@spec create_instance_map_s(t(), integer()) :: {integer(), t()}
|
||||
def create_instance_map_s(eim, map_id) do
|
||||
create_instance_map(eim, map_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets an existing map as part of this event.
|
||||
"""
|
||||
@spec set_instance_map(t(), integer()) :: t()
|
||||
def set_instance_map(%{disposed: true} = eim, _), do: eim
|
||||
def set_instance_map(eim, map_id) do
|
||||
%{eim |
|
||||
map_ids: [map_id | eim.map_ids],
|
||||
is_instanced: [false | eim.is_instanced]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a map instance by index.
|
||||
"""
|
||||
@spec get_map_instance(t(), integer()) :: term()
|
||||
def get_map_instance(eim, index) when index < length(eim.map_ids) do
|
||||
map_id = Enum.at(eim.map_ids, index)
|
||||
is_instanced = Enum.at(eim.is_instanced, index)
|
||||
|
||||
# TODO: Return actual map
|
||||
%{id: map_id, instanced: is_instanced}
|
||||
end
|
||||
def get_map_instance(eim, map_id) when is_integer(map_id) do
|
||||
# Assume it's a real map ID
|
||||
%{id: map_id, instanced: false}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Properties
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Sets a property on this instance.
|
||||
"""
|
||||
@spec set_property(t(), String.t(), String.t()) :: t()
|
||||
def set_property(%{disposed: true} = eim, _, _), do: eim
|
||||
def set_property(eim, key, value) do
|
||||
properties = Map.put(eim.properties, key, value)
|
||||
%{eim | properties: properties}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a property value.
|
||||
"""
|
||||
@spec get_property(t(), String.t()) :: String.t() | nil
|
||||
def get_property(eim, key) do
|
||||
Map.get(eim.properties, key)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Player Actions
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Removes a player from the event (warp out).
|
||||
"""
|
||||
@spec remove_player(t(), Character.t() | integer()) :: t()
|
||||
def remove_player(%{disposed: true} = eim, _), do: eim
|
||||
def remove_player(eim, player) do
|
||||
call_callback(eim, :player_exit, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finishes the party quest.
|
||||
"""
|
||||
@spec finish_pq(t()) :: t()
|
||||
def finish_pq(%{disposed: true} = eim), do: eim
|
||||
def finish_pq(eim) do
|
||||
call_callback(eim, :clear_pq, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Awards achievement to all players.
|
||||
"""
|
||||
@spec give_achievement(t(), integer()) :: :ok
|
||||
def give_achievement(eim, type) do
|
||||
broadcast_to_players(eim, {:achievement, type})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a message to all players in the event.
|
||||
"""
|
||||
@spec broadcast_player_msg(t(), integer(), String.t()) :: :ok
|
||||
def broadcast_player_msg(eim, type, message) do
|
||||
broadcast_to_players(eim, {:message, type, message})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a raw packet to all players.
|
||||
"""
|
||||
@spec broadcast_packet(t(), term()) :: :ok
|
||||
def broadcast_packet(eim, packet) do
|
||||
Enum.each(eim.players, fn char_id ->
|
||||
# TODO: Send packet to player
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts packet to team members.
|
||||
"""
|
||||
@spec broadcast_team_packet(t(), term(), integer()) :: :ok
|
||||
def broadcast_team_packet(eim, packet, team) do
|
||||
# TODO: Filter by team and send
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies buff to a player.
|
||||
"""
|
||||
@spec apply_buff(t(), Character.t() | integer(), integer()) :: :ok
|
||||
def apply_buff(eim, player, buff_id) do
|
||||
# TODO: Apply item effect
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies skill to a player.
|
||||
"""
|
||||
@spec apply_skill(t(), Character.t() | integer(), integer()) :: :ok
|
||||
def apply_skill(eim, player, skill_id) do
|
||||
# TODO: Apply skill effect
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Carnival Party (CPQ)
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Registers a carnival party.
|
||||
"""
|
||||
@spec register_carnival_party(t(), term()) :: t()
|
||||
def register_carnival_party(%{disposed: true} = eim, _), do: eim
|
||||
def register_carnival_party(eim, carnival_party) do
|
||||
call_callback(eim, :register_carnival_party, [carnival_party])
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Disposal
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Disposes the event instance if player count is at or below threshold.
|
||||
|
||||
## Returns
|
||||
- `{true, eim}` - Instance was disposed
|
||||
- `{false, eim}` - Instance not disposed
|
||||
"""
|
||||
@spec dispose_if_player_below(t(), integer(), integer()) :: {boolean(), t()}
|
||||
def dispose_if_player_below(%{disposed: true} = eim, _, _), do: {true, eim}
|
||||
def dispose_if_player_below(eim, size, warp_map_id) do
|
||||
if length(eim.players) <= size do
|
||||
# Warp players if map specified
|
||||
if warp_map_id > 0 do
|
||||
# TODO: Warp all players
|
||||
end
|
||||
|
||||
{true, dispose(eim)}
|
||||
else
|
||||
{false, eim}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disposes the event instance.
|
||||
"""
|
||||
@spec dispose(t()) :: t()
|
||||
def dispose(%{disposed: true} = eim), do: eim
|
||||
def dispose(eim) do
|
||||
# Clear player event instances
|
||||
Enum.each(eim.players, fn char_id ->
|
||||
# TODO: Clear player's event instance reference
|
||||
:ok
|
||||
end)
|
||||
|
||||
# Remove instanced maps
|
||||
Enum.zip(eim.map_ids, eim.is_instanced)
|
||||
|> Enum.each(fn {map_id, instanced} ->
|
||||
if instanced do
|
||||
# TODO: Remove instance map
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
|
||||
# Notify event manager
|
||||
if eim.event_manager do
|
||||
Odinsea.Scripting.EventManager.dispose_instance(eim.name)
|
||||
end
|
||||
|
||||
%{eim |
|
||||
disposed: true,
|
||||
players: [],
|
||||
monsters: [],
|
||||
kill_count: %{},
|
||||
map_ids: [],
|
||||
is_instanced: []
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Utility
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Checks if player is the leader.
|
||||
"""
|
||||
@spec is_leader?(t(), Character.t() | integer()) :: boolean()
|
||||
def is_leader?(_eim, _player) do
|
||||
# TODO: Check party leadership
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player count.
|
||||
"""
|
||||
@spec get_player_count(t()) :: integer()
|
||||
def get_player_count(%{disposed: true}), do: 0
|
||||
def get_player_count(eim), do: length(eim.players)
|
||||
|
||||
@doc """
|
||||
Gets the list of players.
|
||||
"""
|
||||
@spec get_players(t()) :: [integer()]
|
||||
def get_players(%{disposed: true}), do: []
|
||||
def get_players(eim), do: eim.players
|
||||
|
||||
@doc """
|
||||
Gets the list of monsters.
|
||||
"""
|
||||
@spec get_mobs(t()) :: [integer()]
|
||||
def get_mobs(eim), do: eim.monsters
|
||||
|
||||
@doc """
|
||||
Handles map load event.
|
||||
"""
|
||||
@spec on_map_load(t(), Character.t() | integer()) :: t()
|
||||
def on_map_load(%{disposed: true} = eim, _), do: eim
|
||||
def on_map_load(eim, player) do
|
||||
call_callback(eim, :on_map_load, [player])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new pair list (utility for scripts).
|
||||
"""
|
||||
@spec new_pair() :: list()
|
||||
def new_pair(), do: []
|
||||
|
||||
@doc """
|
||||
Adds to a pair list.
|
||||
"""
|
||||
@spec add_to_pair(list(), term(), term()) :: list()
|
||||
def add_to_pair(list, key, value) do
|
||||
[{key, value} | list]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new character pair list.
|
||||
"""
|
||||
@spec new_pair_chr() :: list()
|
||||
def new_pair_chr(), do: []
|
||||
|
||||
@doc """
|
||||
Adds to a character pair list.
|
||||
"""
|
||||
@spec add_to_pair_chr(list(), term(), term()) :: list()
|
||||
def add_to_pair_chr(list, key, value) do
|
||||
[{key, value} | list]
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp call_callback(%{disposed: true} = eim, _method, _args), do: eim
|
||||
defp call_callback(eim, method, args) do
|
||||
if eim.event_manager && eim.event_manager.script_module do
|
||||
mod = eim.event_manager.script_module
|
||||
|
||||
if function_exported?(mod, method, length(args) + 1) do
|
||||
try do
|
||||
apply(mod, method, [eim | args])
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Event callback #{method} error: #{inspect(e)}")
|
||||
eim
|
||||
end
|
||||
else
|
||||
eim
|
||||
end
|
||||
else
|
||||
eim
|
||||
end
|
||||
end
|
||||
|
||||
defp call_callback_result(eim, method, args) do
|
||||
if eim.event_manager && eim.event_manager.script_module do
|
||||
mod = eim.event_manager.script_module
|
||||
|
||||
if function_exported?(mod, method, length(args) + 1) do
|
||||
try do
|
||||
apply(mod, method, [eim | args])
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Event callback #{method} error: #{inspect(e)}")
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_to_players(_eim, _message) do
|
||||
# TODO: Implement broadcasting
|
||||
:ok
|
||||
end
|
||||
|
||||
# Global counter for instance map IDs
|
||||
defp get_new_instance_map_id() do
|
||||
# Use persistent_term or similar for atomic increment
|
||||
:counters.add(:instance_counter, 1, 1)
|
||||
rescue
|
||||
_ ->
|
||||
# Fallback if counter doesn't exist
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
475
lib/odinsea/scripting/event_manager.ex
Normal file
475
lib/odinsea/scripting/event_manager.ex
Normal file
@@ -0,0 +1,475 @@
|
||||
defmodule Odinsea.Scripting.EventManager do
|
||||
@moduledoc """
|
||||
Event Script Manager for handling game events and party quests.
|
||||
|
||||
Event scripts run continuously on channel servers and manage instances
|
||||
of party quests, special events, and scheduled activities.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Event scripts receive an `em` (event manager) object with these callbacks:
|
||||
|
||||
- `init/1` - Called when event is loaded
|
||||
- `schedule/3` - Schedule a method callback after delay
|
||||
- `setup/2` - Called to create a new event instance
|
||||
- `player_entry/2` - Player enters instance
|
||||
- `player_dead/2` - Player dies
|
||||
- `player_revive/2` - Player revives
|
||||
- `player_disconnected/2` - Player disconnects
|
||||
- `monster_value/2` - Monster killed, returns points
|
||||
- `all_monsters_dead/1` - All monsters killed
|
||||
- `scheduled_timeout/1` - Event timer expired
|
||||
- `left_party/2` - Player left party
|
||||
- `disband_party/1` - Party disbanded
|
||||
- `clear_pq/1` - Party quest cleared
|
||||
- `player_exit/2` - Player exits event
|
||||
- `cancel_schedule/1` - Event cancelled
|
||||
|
||||
## Example Event Script
|
||||
|
||||
defmodule Odinsea.Scripting.Event.Boats do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.EventManager
|
||||
|
||||
@impl true
|
||||
def init(em) do
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def schedule(em, "stopentry", _delay) do
|
||||
set_property(em, "entry", "false")
|
||||
end
|
||||
|
||||
def schedule(em, "takeoff", _delay) do
|
||||
warp_all_player(em, 200000112, 200090000)
|
||||
schedule(em, "arrived", 420_000)
|
||||
end
|
||||
|
||||
def schedule(em, "arrived", _delay) do
|
||||
warp_all_player(em, 200090000, 101000300)
|
||||
schedule_new(em)
|
||||
end
|
||||
|
||||
defp schedule_new(em) do
|
||||
set_property(em, "docked", "true")
|
||||
set_property(em, "entry", "true")
|
||||
schedule(em, "stopentry", 240_000)
|
||||
schedule(em, "takeoff", 300_000)
|
||||
end
|
||||
end
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, EventInstance}
|
||||
|
||||
# ETS tables
|
||||
@event_scripts :event_scripts
|
||||
@event_instances :event_instances
|
||||
@event_properties :event_properties
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
channel: integer(),
|
||||
script_module: module() | nil,
|
||||
properties: map()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:channel,
|
||||
:script_module,
|
||||
:properties
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the event script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads and initializes event scripts.
|
||||
|
||||
## Parameters
|
||||
- `scripts` - List of event script names (without .js extension)
|
||||
- `channel` - Channel server number
|
||||
|
||||
## Returns
|
||||
- `{:ok, count}` - Number of events loaded
|
||||
"""
|
||||
@spec load_events([String.t()], integer()) :: {:ok, integer()} | {:error, term()}
|
||||
def load_events(scripts, channel) do
|
||||
GenServer.call(__MODULE__, {:load_events, scripts, channel})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an event manager for a specific event.
|
||||
|
||||
## Parameters
|
||||
- `event_name` - Name of the event
|
||||
|
||||
## Returns
|
||||
- `{:ok, em}` - Event manager struct
|
||||
- `{:error, :not_found}` - Event not loaded
|
||||
"""
|
||||
@spec get_event(String.t()) :: {:ok, t()} | {:error, term()}
|
||||
def get_event(event_name) do
|
||||
case :ets.lookup(@event_scripts, event_name) do
|
||||
[{^event_name, em}] -> {:ok, em}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new event instance.
|
||||
|
||||
## Parameters
|
||||
- `event_name` - Name of the event
|
||||
- `instance_name` - Unique name for this instance
|
||||
- `args` - Arguments to pass to setup
|
||||
|
||||
## Returns
|
||||
- `{:ok, eim}` - Event instance created
|
||||
- `{:error, reason}` - Failed to create
|
||||
"""
|
||||
@spec new_instance(String.t(), String.t(), term()) ::
|
||||
{:ok, EventInstance.t()} | {:error, term()}
|
||||
def new_instance(event_name, instance_name, args \\ nil) do
|
||||
GenServer.call(__MODULE__, {:new_instance, event_name, instance_name, args})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an existing event instance.
|
||||
"""
|
||||
@spec get_instance(String.t()) :: {:ok, EventInstance.t()} | {:error, term()}
|
||||
def get_instance(instance_name) do
|
||||
case :ets.lookup(@event_instances, instance_name) do
|
||||
[{^instance_name, eim}] -> {:ok, eim}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disposes of an event instance.
|
||||
"""
|
||||
@spec dispose_instance(String.t()) :: :ok
|
||||
def dispose_instance(instance_name) do
|
||||
GenServer.call(__MODULE__, {:dispose_instance, instance_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Schedules a method callback on an event.
|
||||
|
||||
## Parameters
|
||||
- `em` - Event manager or instance
|
||||
- `method_name` - Name of the method to call
|
||||
- `delay_ms` - Delay in milliseconds
|
||||
"""
|
||||
@spec schedule(t() | EventInstance.t(), String.t(), integer()) :: reference()
|
||||
def schedule(em_or_eim, method_name, delay_ms) do
|
||||
GenServer.call(__MODULE__, {:schedule, em_or_eim, method_name, delay_ms})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels all scheduled tasks for an event.
|
||||
"""
|
||||
@spec cancel(t()) :: :ok
|
||||
def cancel(em) do
|
||||
GenServer.call(__MODULE__, {:cancel, em.name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a property on an event manager.
|
||||
"""
|
||||
@spec set_property(t(), String.t(), String.t()) :: :ok
|
||||
def set_property(em, key, value) do
|
||||
GenServer.call(__MODULE__, {:set_property, em.name, key, value})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a property from an event manager.
|
||||
"""
|
||||
@spec get_property(t(), String.t()) :: String.t() | nil
|
||||
def get_property(em, key) do
|
||||
case :ets.lookup(@event_properties, {em.name, key}) do
|
||||
[{_, value}] -> value
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps all players from one map to another.
|
||||
|
||||
## Parameters
|
||||
- `em` - Event manager
|
||||
- `from_map` - Source map ID
|
||||
- `to_map` - Destination map ID
|
||||
"""
|
||||
@spec warp_all_player(t(), integer(), integer()) :: :ok
|
||||
def warp_all_player(em, from_map, to_map) do
|
||||
Logger.debug("Event #{em.name}: Warp all from #{from_map} to #{to_map}")
|
||||
# TODO: Implement warp all
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a ship effect to a map.
|
||||
"""
|
||||
@spec broadcast_ship(t(), integer(), integer()) :: :ok
|
||||
def broadcast_ship(em, map_id, effect) do
|
||||
Logger.debug("Event #{em.name}: Broadcast ship effect #{effect} to map #{map_id}")
|
||||
# TODO: Send boat packet
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a yellow message to the channel.
|
||||
"""
|
||||
@spec broadcast_yellow_msg(t(), String.t()) :: :ok
|
||||
def broadcast_yellow_msg(em, message) do
|
||||
Logger.debug("Event #{em.name}: Yellow message: #{message}")
|
||||
# TODO: Broadcast to channel
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a server message.
|
||||
"""
|
||||
@spec broadcast_server_msg(t(), integer(), String.t(), boolean()) :: :ok
|
||||
def broadcast_server_msg(em, type, message, weather \\ false) do
|
||||
Logger.debug("Event #{em.name}: Server message (#{type}): #{message}")
|
||||
# TODO: Broadcast
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the map factory for creating map instances.
|
||||
"""
|
||||
@spec get_map_factory(t()) :: term()
|
||||
def get_map_factory(_em) do
|
||||
# TODO: Return map factory
|
||||
nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a monster by ID.
|
||||
"""
|
||||
@spec get_monster(t(), integer()) :: term()
|
||||
def get_monster(_em, mob_id) do
|
||||
# TODO: Return monster data
|
||||
%{id: mob_id}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a reactor by ID.
|
||||
"""
|
||||
@spec get_reactor(t(), integer()) :: term()
|
||||
def get_reactor(_em, reactor_id) do
|
||||
# TODO: Return reactor data
|
||||
%{id: reactor_id}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates new monster stats for overriding.
|
||||
"""
|
||||
@spec new_monster_stats() :: map()
|
||||
def new_monster_stats() do
|
||||
%{}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new character list.
|
||||
"""
|
||||
@spec new_char_list() :: list()
|
||||
def new_char_list() do
|
||||
[]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the EXP rate for the channel.
|
||||
"""
|
||||
@spec get_exp_rate(t()) :: integer()
|
||||
def get_exp_rate(_em) do
|
||||
# TODO: Get from channel config
|
||||
1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the channel server.
|
||||
"""
|
||||
@spec get_channel_server(t()) :: term()
|
||||
def get_channel_server(em) do
|
||||
# TODO: Return channel server
|
||||
%{channel: em.channel}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the channel number.
|
||||
"""
|
||||
@spec get_channel(t()) :: integer()
|
||||
def get_channel(em) do
|
||||
em.channel
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@event_scripts, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@event_instances, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@event_properties, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Event Script Manager initialized")
|
||||
|
||||
{:ok, %{timers: %{}}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_events, scripts, channel}, _from, state) do
|
||||
count = Enum.count(scripts, fn script_name ->
|
||||
case Manager.get_script(:event, script_name) do
|
||||
{:ok, module} ->
|
||||
em = %__MODULE__{
|
||||
name: script_name,
|
||||
channel: channel,
|
||||
script_module: module,
|
||||
properties: %{}
|
||||
}
|
||||
|
||||
:ets.insert(@event_scripts, {script_name, em})
|
||||
|
||||
# Call init if available
|
||||
if function_exported?(module, :init, 1) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
module.init(em)
|
||||
rescue
|
||||
e -> Logger.error("Event #{script_name} init error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
true
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to load event #{script_name}: #{inspect(reason)}")
|
||||
false
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, {:ok, count}, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:new_instance, event_name, instance_name, args}, _from, state) do
|
||||
case :ets.lookup(@event_scripts, event_name) do
|
||||
[{^event_name, em}] ->
|
||||
# Create event instance
|
||||
eim = EventInstance.new(em, instance_name, em.channel)
|
||||
:ets.insert(@event_instances, {instance_name, eim})
|
||||
|
||||
# Call setup
|
||||
if em.script_module && function_exported?(em.script_module, :setup, 2) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
em.script_module.setup(eim, args)
|
||||
rescue
|
||||
e -> Logger.error("Event #{event_name} setup error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:reply, {:ok, eim}, state}
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :event_not_found}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:dispose_instance, instance_name}, _from, state) do
|
||||
:ets.delete(@event_instances, instance_name)
|
||||
|
||||
# Cancel any timers for this instance
|
||||
timers = Map.get(state.timers, instance_name, [])
|
||||
Enum.each(timers, fn ref ->
|
||||
Process.cancel_timer(ref)
|
||||
end)
|
||||
|
||||
new_timers = Map.delete(state.timers, instance_name)
|
||||
{:reply, :ok, %{state | timers: new_timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:schedule, em_or_eim, method_name, delay_ms}, _from, state) do
|
||||
instance_name = case em_or_eim do
|
||||
%{name: name} -> name
|
||||
_ -> "unknown"
|
||||
end
|
||||
|
||||
ref = Process.send_after(self(), {:scheduled, em_or_eim, method_name}, delay_ms)
|
||||
|
||||
timers = Map.update(state.timers, instance_name, [ref], &[ref | &1])
|
||||
|
||||
{:reply, ref, %{state | timers: timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:cancel, event_name}, _from, state) do
|
||||
timers = Map.get(state.timers, event_name, [])
|
||||
Enum.each(timers, fn ref ->
|
||||
Process.cancel_timer(ref)
|
||||
end)
|
||||
|
||||
new_timers = Map.delete(state.timers, event_name)
|
||||
{:reply, :ok, %{state | timers: new_timers}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_property, event_name, key, value}, _from, state) do
|
||||
:ets.insert(@event_properties, {{event_name, key}, value})
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:scheduled, em_or_eim, method_name}, state) do
|
||||
# Find script module
|
||||
script_module = case em_or_eim do
|
||||
%{__struct__: Odinsea.Scripting.EventManager, script_module: mod} -> mod
|
||||
%{__struct__: Odinsea.Scripting.EventInstance, event_manager: em} -> em.script_module
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
if script_module && function_exported?(script_module, :schedule, 3) do
|
||||
Task.start(fn ->
|
||||
try do
|
||||
script_module.schedule(em_or_eim, method_name, 0)
|
||||
rescue
|
||||
e -> Logger.error("Scheduled event error: #{inspect(e)}")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
413
lib/odinsea/scripting/manager.ex
Normal file
413
lib/odinsea/scripting/manager.ex
Normal file
@@ -0,0 +1,413 @@
|
||||
defmodule Odinsea.Scripting.Manager do
|
||||
@moduledoc """
|
||||
Base script manager for loading and caching game scripts.
|
||||
|
||||
This module provides functionality for:
|
||||
- Loading script files from disk
|
||||
- Caching compiled scripts in ETS
|
||||
- Hot-reloading scripts without server restart
|
||||
- Resolving script modules by name
|
||||
|
||||
## Script Loading
|
||||
|
||||
Scripts are loaded from the `scripts/` directory with the following structure:
|
||||
- `scripts/npc/` - NPC conversation scripts (857 files)
|
||||
- `scripts/portal/` - Portal scripts (700 files)
|
||||
- `scripts/event/` - Event scripts (95 files)
|
||||
- `scripts/quest/` - Quest scripts (445 files)
|
||||
- `scripts/reactor/` - Reactor scripts (272 files)
|
||||
|
||||
## Hot Reload
|
||||
|
||||
When `script_reload` is enabled in configuration, scripts are reloaded
|
||||
from disk on each invocation (useful for development).
|
||||
|
||||
## Script Compilation
|
||||
|
||||
Scripts can be implemented as:
|
||||
1. Elixir modules compiled at build time
|
||||
2. Elixir modules compiled dynamically at runtime (Code.eval_string)
|
||||
3. JavaScript executed via QuickJS (future enhancement)
|
||||
4. Lua executed via luerl (future enhancement)
|
||||
|
||||
## Configuration
|
||||
|
||||
config :odinsea, Odinsea.Scripting,
|
||||
script_reload: true, # Enable hot-reload in development
|
||||
scripts_path: "priv/scripts" # Path to script files
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Behavior, PlayerAPI}
|
||||
|
||||
# ETS table names for caching
|
||||
@script_cache :script_cache
|
||||
@script_timestamps :script_timestamps
|
||||
|
||||
# Script types
|
||||
@script_types [:npc, :portal, :event, :quest, :reactor]
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type script_type :: :npc | :portal | :event | :quest | :reactor
|
||||
@type script_module :: module()
|
||||
@type script_result :: {:ok, script_module()} | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads all scripts from the scripts directory.
|
||||
|
||||
## Parameters
|
||||
- `type` - Optional script type to load (nil loads all)
|
||||
|
||||
## Returns
|
||||
- `{:ok, count}` - Number of scripts loaded
|
||||
- `{:error, reason}` - Loading failed
|
||||
"""
|
||||
@spec load_all(script_type() | nil) :: {:ok, integer()} | {:error, term()}
|
||||
def load_all(type \\ nil) do
|
||||
GenServer.call(__MODULE__, {:load_all, type})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a single script file.
|
||||
|
||||
## Parameters
|
||||
- `path` - Relative path within scripts directory (e.g., "npc/1002001.js")
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script loaded successfully
|
||||
- `{:error, reason}` - Loading failed
|
||||
"""
|
||||
@spec load_script(String.t()) :: script_result()
|
||||
def load_script(path) do
|
||||
GenServer.call(__MODULE__, {:load_script, path})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a cached script module.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type (:npc, :portal, etc.)
|
||||
- `name` - Script name (e.g., "1002001", "08_xmas_st")
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script found
|
||||
- `{:error, :not_found}` - Script not found
|
||||
"""
|
||||
@spec get_script(script_type(), String.t()) :: script_result()
|
||||
def get_script(type, name) do
|
||||
case :ets.lookup(@script_cache, {type, name}) do
|
||||
[{_, module}] ->
|
||||
if script_reload?() do
|
||||
# Reload if hot-reload is enabled
|
||||
reload_script(type, name)
|
||||
else
|
||||
{:ok, module}
|
||||
end
|
||||
[] ->
|
||||
# Try to load from file
|
||||
load_and_cache(type, name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads a script from disk.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script reloaded successfully
|
||||
- `{:error, reason}` - Reload failed
|
||||
"""
|
||||
@spec reload_script(script_type(), String.t()) :: script_result()
|
||||
def reload_script(type, name) do
|
||||
GenServer.call(__MODULE__, {:reload_script, type, name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached scripts.
|
||||
"""
|
||||
@spec clear_cache() :: :ok
|
||||
def clear_cache() do
|
||||
GenServer.call(__MODULE__, :clear_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the file path for a script.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- File path as string
|
||||
"""
|
||||
@spec script_path(script_type(), String.t()) :: String.t()
|
||||
def script_path(type, name) do
|
||||
base = scripts_path()
|
||||
ext = script_extension()
|
||||
Path.join([base, to_string(type), "#{name}#{ext}"])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a script file exists.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
- `name` - Script name
|
||||
|
||||
## Returns
|
||||
- `true` - Script exists
|
||||
- `false` - Script does not exist
|
||||
"""
|
||||
@spec script_exists?(script_type(), String.t()) :: boolean()
|
||||
def script_exists?(type, name) do
|
||||
script_path(type, name)
|
||||
|> File.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available scripts of a given type.
|
||||
|
||||
## Parameters
|
||||
- `type` - Script type
|
||||
|
||||
## Returns
|
||||
- List of script names
|
||||
"""
|
||||
@spec list_scripts(script_type()) :: [String.t()]
|
||||
def list_scripts(type) do
|
||||
base = Path.join(scripts_path(), to_string(type))
|
||||
ext = script_extension()
|
||||
|
||||
case File.ls(base) do
|
||||
{:ok, files} ->
|
||||
files
|
||||
|> Enum.filter(&String.ends_with?(&1, ext))
|
||||
|> Enum.map(&String.replace_suffix(&1, ext, ""))
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compiles a script file into an Elixir module.
|
||||
|
||||
This is a stub implementation that can be extended to support:
|
||||
- JavaScript via QuickJS
|
||||
- Lua via luerl
|
||||
- Direct Elixir modules
|
||||
|
||||
## Parameters
|
||||
- `source` - Script source code
|
||||
- `module_name` - Name for the compiled module
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Compilation successful
|
||||
- `{:error, reason}` - Compilation failed
|
||||
"""
|
||||
@spec compile_script(String.t(), module()) :: script_result()
|
||||
def compile_script(source, module_name) do
|
||||
# Stub implementation - creates a minimal module
|
||||
# In production, this would parse JavaScript/Lua and generate Elixir code
|
||||
# or compile to bytecode for a JS/Lua runtime
|
||||
|
||||
try do
|
||||
# For now, create a stub module
|
||||
# This would be replaced with actual JS/Lua compilation
|
||||
ast = quote do
|
||||
defmodule unquote(module_name) do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
# Stub implementations
|
||||
def start(_api), do: :ok
|
||||
def action(_api, _mode, _type, _selection), do: :ok
|
||||
def enter(_api), do: :ok
|
||||
def act(_api), do: :ok
|
||||
def init(_em), do: :ok
|
||||
def setup(_em, _args), do: :ok
|
||||
end
|
||||
end
|
||||
|
||||
Code.eval_quoted(ast)
|
||||
{:ok, module_name}
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Script compilation failed: #{inspect(e)}")
|
||||
{:error, :compilation_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Configuration Helpers
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Returns the base path for scripts.
|
||||
"""
|
||||
@spec scripts_path() :: String.t()
|
||||
def scripts_path() do
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:scripts_path, "priv/scripts")
|
||||
|> Path.expand()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether hot-reload is enabled.
|
||||
"""
|
||||
@spec script_reload?() :: boolean()
|
||||
def script_reload?() do
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:script_reload, false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the script file extension.
|
||||
"""
|
||||
@spec script_extension() :: String.t()
|
||||
def script_extension() do
|
||||
# Could be .js for JavaScript, .lua for Lua, .ex for Elixir
|
||||
Application.get_env(:odinsea, __MODULE__, [])
|
||||
|> Keyword.get(:script_extension, ".ex")
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables for caching
|
||||
:ets.new(@script_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@script_timestamps, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Script Manager initialized")
|
||||
|
||||
{:ok, %{loaded: 0}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_all, type}, _from, state) do
|
||||
types = if type, do: [type], else: @script_types
|
||||
|
||||
count = Enum.reduce(types, 0, fn script_type, acc ->
|
||||
scripts = list_scripts(script_type)
|
||||
loaded = Enum.count(scripts, fn name ->
|
||||
case load_and_cache(script_type, name) do
|
||||
{:ok, _} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end)
|
||||
acc + loaded
|
||||
end)
|
||||
|
||||
Logger.info("Loaded #{count} scripts")
|
||||
{:reply, {:ok, count}, %{state | loaded: count}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, path}, _from, state) do
|
||||
result = do_load_script(path)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:reload_script, type, name}, _from, state) do
|
||||
# Remove from cache
|
||||
:ets.delete(@script_cache, {type, name})
|
||||
:ets.delete(@script_timestamps, {type, name})
|
||||
|
||||
# Reload
|
||||
result = load_and_cache(type, name)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_cache, _from, state) do
|
||||
:ets.delete_all_objects(@script_cache)
|
||||
:ets.delete_all_objects(@script_timestamps)
|
||||
Logger.info("Script cache cleared")
|
||||
{:reply, :ok, %{state | loaded: 0}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp load_and_cache(type, name) do
|
||||
path = script_path(type, name)
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, source} ->
|
||||
module_name = module_name_for(type, name)
|
||||
|
||||
case compile_script(source, module_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@script_cache, {{type, name}, module})
|
||||
:ets.insert(@script_timestamps, {{type, name}, File.stat!(path).mtime})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to compile script #{path}: #{inspect(reason)}")
|
||||
{:error, :compilation_failed}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.debug("Script not found: #{path}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_load_script(path) do
|
||||
full_path = Path.join(scripts_path(), path)
|
||||
|
||||
case File.read(full_path) do
|
||||
{:ok, source} ->
|
||||
# Determine type and name from path
|
||||
[type_str, filename] = Path.split(path)
|
||||
name = Path.rootname(filename)
|
||||
type = String.to_existing_atom(type_str)
|
||||
module_name = module_name_for(type, name)
|
||||
|
||||
compile_script(source, module_name)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp module_name_for(type, name) do
|
||||
# Generate a valid Elixir module name
|
||||
# e.g., Odinsea.Scripting.NPC.Script_1002001
|
||||
type_module = type |> to_string() |> Macro.camelize()
|
||||
safe_name = sanitize_module_name(name)
|
||||
Module.concat(["Odinsea", "Scripting", type_module, "Script_#{safe_name}"])
|
||||
end
|
||||
|
||||
defp sanitize_module_name(name) do
|
||||
# Convert script name to valid module name
|
||||
name
|
||||
|> String.replace(~r/[^a-zA-Z0-9_]/, "_")
|
||||
|> String.replace_prefix("", "Script_")
|
||||
end
|
||||
end
|
||||
546
lib/odinsea/scripting/npc_manager.ex
Normal file
546
lib/odinsea/scripting/npc_manager.ex
Normal file
@@ -0,0 +1,546 @@
|
||||
defmodule Odinsea.Scripting.NPCManager do
|
||||
@moduledoc """
|
||||
NPC Script Manager for handling NPC conversations.
|
||||
|
||||
Manages the lifecycle of NPC interactions including:
|
||||
- Starting conversations with NPCs
|
||||
- Handling player responses (yes/no, menu selections, text input)
|
||||
- Quest start/end conversations
|
||||
- Multiple concurrent conversations per player
|
||||
|
||||
## Conversation State
|
||||
|
||||
Each active conversation tracks:
|
||||
- Player/Client reference
|
||||
- NPC ID
|
||||
- Quest ID (for quest conversations)
|
||||
- Conversation type (:npc, :quest_start, :quest_end)
|
||||
- Last message type (for input validation)
|
||||
- Pending disposal flag
|
||||
|
||||
## Script Interface
|
||||
|
||||
NPC scripts receive a `cm` (conversation manager) object with methods:
|
||||
- `send_ok/1` - Show OK dialog
|
||||
- `send_yes_no/1` - Show Yes/No dialog
|
||||
- `send_simple/1` - Show menu selection
|
||||
- `send_get_text/1` - Request text input
|
||||
- `send_get_number/4` - Request number input
|
||||
- `send_style/2` - Show style selection
|
||||
- `warp/2` - Warp player to map
|
||||
- `gain_item/2` - Give player items
|
||||
- `dispose/0` - End conversation
|
||||
|
||||
## Example Script (Elixir)
|
||||
|
||||
defmodule Odinsea.Scripting.NPC.Script_1002001 do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
|
||||
@impl true
|
||||
def start(cm) do
|
||||
PlayerAPI.send_ok(cm, "Hello! I'm Cody. Nice to meet you!")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def action(cm, _mode, _type, _selection) do
|
||||
PlayerAPI.dispose(cm)
|
||||
end
|
||||
end
|
||||
|
||||
## JavaScript Compatibility
|
||||
|
||||
For JavaScript scripts, the following globals are available:
|
||||
- `cm` - Conversation manager API
|
||||
- `status` - Conversation status variable (for legacy scripts)
|
||||
|
||||
Entry points:
|
||||
- `function start()` - Called when conversation starts
|
||||
- `function action(mode, type, selection)` - Called on player response
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, PlayerAPI}
|
||||
|
||||
# Conversation types
|
||||
@type conv_type :: :npc | :quest_start | :quest_end
|
||||
|
||||
# Conversation state
|
||||
defmodule Conversation do
|
||||
@moduledoc "Represents an active NPC conversation."
|
||||
|
||||
defstruct [
|
||||
:client_pid, # Player's client process
|
||||
:character_id, # Character ID
|
||||
:npc_id, # NPC template ID
|
||||
:quest_id, # Quest ID (for quest conversations)
|
||||
:type, # :npc, :quest_start, :quest_end
|
||||
:script_module, # Compiled script module
|
||||
:last_msg, # Last message type sent (-1 = none)
|
||||
:pending_disposal, # Flag to dispose on next action
|
||||
:script_name # Custom script name override
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
client_pid: pid(),
|
||||
character_id: integer(),
|
||||
npc_id: integer(),
|
||||
quest_id: integer() | nil,
|
||||
type: Odinsea.Scripting.NPCManager.conv_type(),
|
||||
script_module: module() | nil,
|
||||
last_msg: integer(),
|
||||
pending_disposal: boolean(),
|
||||
script_name: String.t() | nil
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the NPC script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts an NPC conversation with a player.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
- `npc_id` - The NPC template ID
|
||||
- `opts` - Options:
|
||||
- `:script_name` - Override script name (default: npc_id)
|
||||
|
||||
## Returns
|
||||
- `:ok` - Conversation started
|
||||
- `{:error, :already_talking}` - Player already in conversation
|
||||
- `{:error, :script_not_found}` - NPC script not found
|
||||
"""
|
||||
@spec start_conversation(pid(), integer(), integer(), keyword()) ::
|
||||
:ok | {:error, term()}
|
||||
def start_conversation(client_pid, character_id, npc_id, opts \\ []) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:start_conversation,
|
||||
client_pid,
|
||||
character_id,
|
||||
npc_id,
|
||||
:npc,
|
||||
nil,
|
||||
opts
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts a quest conversation (start quest).
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
- `npc_id` - The NPC template ID
|
||||
- `quest_id` - The quest ID
|
||||
|
||||
## Returns
|
||||
- `:ok` - Conversation started
|
||||
- `{:error, reason}` - Failed to start
|
||||
"""
|
||||
@spec start_quest(pid(), integer(), integer(), integer()) ::
|
||||
:ok | {:error, term()}
|
||||
def start_quest(client_pid, character_id, npc_id, quest_id) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:start_conversation,
|
||||
client_pid,
|
||||
character_id,
|
||||
npc_id,
|
||||
:quest_start,
|
||||
quest_id,
|
||||
[]
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ends a quest conversation (complete quest).
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
- `npc_id` - The NPC template ID
|
||||
- `quest_id` - The quest ID
|
||||
|
||||
## Returns
|
||||
- `:ok` - Conversation started
|
||||
- `{:error, reason}` - Failed to start
|
||||
"""
|
||||
@spec end_quest(pid(), integer(), integer(), integer()) ::
|
||||
:ok | {:error, term()}
|
||||
def end_quest(client_pid, character_id, npc_id, quest_id) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:start_conversation,
|
||||
client_pid,
|
||||
character_id,
|
||||
npc_id,
|
||||
:quest_end,
|
||||
quest_id,
|
||||
[]
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player action in an ongoing conversation.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
- `mode` - Action mode (0 = end, 1 = yes/next)
|
||||
- `type` - Action type (usually 0)
|
||||
- `selection` - Menu selection index
|
||||
|
||||
## Returns
|
||||
- `:ok` - Action handled
|
||||
- `{:error, :no_conversation}` - No active conversation
|
||||
"""
|
||||
@spec handle_action(pid(), integer(), integer(), integer(), integer()) ::
|
||||
:ok | {:error, term()}
|
||||
def handle_action(client_pid, character_id, mode, type, selection) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:handle_action,
|
||||
client_pid,
|
||||
character_id,
|
||||
mode,
|
||||
type,
|
||||
selection
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disposes (ends) a conversation.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
|
||||
## Returns
|
||||
- `:ok` - Conversation disposed
|
||||
"""
|
||||
@spec dispose(pid(), integer()) :: :ok
|
||||
def dispose(client_pid, character_id) do
|
||||
GenServer.call(__MODULE__, {:dispose, client_pid, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Safely disposes a conversation on the next action.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
|
||||
## Returns
|
||||
- `:ok` - Pending disposal set
|
||||
- `{:error, :no_conversation}` - No active conversation
|
||||
"""
|
||||
@spec safe_dispose(pid(), integer()) :: :ok | {:error, term()}
|
||||
def safe_dispose(client_pid, character_id) do
|
||||
GenServer.call(__MODULE__, {:safe_dispose, client_pid, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the conversation manager for a player.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
|
||||
## Returns
|
||||
- `{:ok, cm}` - Conversation manager
|
||||
- `{:error, :no_conversation}` - No active conversation
|
||||
"""
|
||||
@spec get_cm(pid(), integer()) :: {:ok, PlayerAPI.t()} | {:error, term()}
|
||||
def get_cm(client_pid, character_id) do
|
||||
GenServer.call(__MODULE__, {:get_cm, client_pid, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is currently in a conversation.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
|
||||
## Returns
|
||||
- `true` - Player is in conversation
|
||||
- `false` - Player is not in conversation
|
||||
"""
|
||||
@spec in_conversation?(pid(), integer()) :: boolean()
|
||||
def in_conversation?(client_pid, character_id) do
|
||||
case get_cm(client_pid, character_id) do
|
||||
{:ok, _} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the last message type for input validation.
|
||||
|
||||
## Parameters
|
||||
- `client_pid` - The player's client process
|
||||
- `character_id` - The character ID
|
||||
- `msg_type` - Message type code
|
||||
"""
|
||||
@spec set_last_msg(pid(), integer(), integer()) :: :ok
|
||||
def set_last_msg(client_pid, character_id, msg_type) do
|
||||
GenServer.call(__MODULE__, {:set_last_msg, client_pid, character_id, msg_type})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# ETS table for active conversations: {{client_pid, character_id}, conversation}
|
||||
:ets.new(:npc_conversations, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("NPC Script Manager initialized")
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:start_conversation, client_pid, character_id, npc_id, type, quest_id, opts}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
|
||||
# Check if already in conversation
|
||||
case :ets.lookup(:npc_conversations, key) do
|
||||
[{_, _existing}] ->
|
||||
{:reply, {:error, :already_talking}, state}
|
||||
|
||||
[] ->
|
||||
# Determine script name
|
||||
script_name = opts[:script_name] || to_string(npc_id)
|
||||
|
||||
# Load script based on type
|
||||
script_result = case type do
|
||||
:npc ->
|
||||
Manager.get_script(:npc, script_name)
|
||||
|
||||
:quest_start ->
|
||||
Manager.get_script(:quest, to_string(quest_id))
|
||||
|
||||
:quest_end ->
|
||||
Manager.get_script(:quest, to_string(quest_id))
|
||||
end
|
||||
|
||||
case script_result do
|
||||
{:ok, script_module} ->
|
||||
# Create conversation record
|
||||
conv = %Conversation{
|
||||
client_pid: client_pid,
|
||||
character_id: character_id,
|
||||
npc_id: npc_id,
|
||||
quest_id: quest_id,
|
||||
type: type,
|
||||
script_module: script_module,
|
||||
last_msg: -1,
|
||||
pending_disposal: false,
|
||||
script_name: script_name
|
||||
}
|
||||
|
||||
# Store conversation
|
||||
:ets.insert(:npc_conversations, {key, conv})
|
||||
|
||||
# Create conversation manager API
|
||||
cm = PlayerAPI.new(client_pid, character_id, npc_id, quest_id, self())
|
||||
|
||||
# Call script start function
|
||||
Task.start(fn ->
|
||||
try do
|
||||
# Set the script engine in client state if needed
|
||||
# For now, directly call the behavior callback
|
||||
case type do
|
||||
:quest_start ->
|
||||
if function_exported?(script_module, :quest_start, 4) do
|
||||
script_module.quest_start(cm, 1, 0, 0)
|
||||
else
|
||||
script_module.action(cm, 1, 0, 0)
|
||||
end
|
||||
|
||||
:quest_end ->
|
||||
if function_exported?(script_module, :quest_end, 4) do
|
||||
script_module.quest_end(cm, 1, 0, 0)
|
||||
else
|
||||
script_module.action(cm, 1, 0, 0)
|
||||
end
|
||||
|
||||
_ ->
|
||||
if function_exported?(script_module, :start, 1) do
|
||||
script_module.start(cm)
|
||||
else
|
||||
# Try action as fallback
|
||||
script_module.action(cm, 1, 0, 0)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("NPC script error: #{inspect(e)}")
|
||||
dispose(client_pid, character_id)
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, :ok, state}
|
||||
|
||||
{:error, :enoent} ->
|
||||
# Script not found - use default "notcoded" script
|
||||
case Manager.get_script(:npc, "notcoded") do
|
||||
{:ok, script_module} ->
|
||||
conv = %Conversation{
|
||||
client_pid: client_pid,
|
||||
character_id: character_id,
|
||||
npc_id: npc_id,
|
||||
quest_id: quest_id,
|
||||
type: type,
|
||||
script_module: script_module,
|
||||
last_msg: -1,
|
||||
pending_disposal: false,
|
||||
script_name: "notcoded"
|
||||
}
|
||||
|
||||
:ets.insert(:npc_conversations, {key, conv})
|
||||
|
||||
cm = PlayerAPI.new(client_pid, character_id, npc_id, quest_id, self())
|
||||
|
||||
Task.start(fn ->
|
||||
try do
|
||||
script_module.start(cm)
|
||||
rescue
|
||||
_ -> dispose(client_pid, character_id)
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, :ok, state}
|
||||
|
||||
{:error, _} ->
|
||||
{:reply, {:error, :script_not_found}, state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:handle_action, client_pid, character_id, mode, type, selection}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
|
||||
case :ets.lookup(:npc_conversations, key) do
|
||||
[{_, conv}] when conv.pending_disposal ->
|
||||
# Dispose and reply
|
||||
:ets.delete(:npc_conversations, key)
|
||||
{:reply, :ok, state}
|
||||
|
||||
[{_, conv}] when conv.last_msg > -1 ->
|
||||
# Already sent a message, ignore
|
||||
{:reply, :ok, state}
|
||||
|
||||
[{_, conv}] ->
|
||||
if mode == -1 do
|
||||
# Cancel/end
|
||||
:ets.delete(:npc_conversations, key)
|
||||
{:reply, :ok, state}
|
||||
else
|
||||
cm = PlayerAPI.new(client_pid, character_id, conv.npc_id, conv.quest_id, self())
|
||||
|
||||
Task.start(fn ->
|
||||
try do
|
||||
case conv.type do
|
||||
:quest_start ->
|
||||
if function_exported?(conv.script_module, :quest_start, 4) do
|
||||
conv.script_module.quest_start(cm, mode, type, selection)
|
||||
else
|
||||
conv.script_module.action(cm, mode, type, selection)
|
||||
end
|
||||
|
||||
:quest_end ->
|
||||
if function_exported?(conv.script_module, :quest_end, 4) do
|
||||
conv.script_module.quest_end(cm, mode, type, selection)
|
||||
else
|
||||
conv.script_module.action(cm, mode, type, selection)
|
||||
end
|
||||
|
||||
_ ->
|
||||
conv.script_module.action(cm, mode, type, selection)
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("NPC action error: #{inspect(e)}")
|
||||
dispose(client_pid, character_id)
|
||||
end
|
||||
end)
|
||||
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :no_conversation}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:dispose, client_pid, character_id}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
:ets.delete(:npc_conversations, key)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:safe_dispose, client_pid, character_id}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
|
||||
case :ets.lookup(:npc_conversations, key) do
|
||||
[{_, conv}] ->
|
||||
updated = %{conv | pending_disposal: true}
|
||||
:ets.insert(:npc_conversations, {key, updated})
|
||||
{:reply, :ok, state}
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :no_conversation}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_cm, client_pid, character_id}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
|
||||
case :ets.lookup(:npc_conversations, key) do
|
||||
[{_, conv}] ->
|
||||
cm = PlayerAPI.new(client_pid, character_id, conv.npc_id, conv.quest_id, self())
|
||||
{:reply, {:ok, cm}, state}
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :no_conversation}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_last_msg, client_pid, character_id, msg_type}, _from, state) do
|
||||
key = {client_pid, character_id}
|
||||
|
||||
case :ets.lookup(:npc_conversations, key) do
|
||||
[{_, conv}] ->
|
||||
updated = %{conv | last_msg: msg_type}
|
||||
:ets.insert(:npc_conversations, {key, updated})
|
||||
{:reply, :ok, state}
|
||||
|
||||
[] ->
|
||||
{:reply, {:error, :no_conversation}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
1493
lib/odinsea/scripting/player_api.ex
Normal file
1493
lib/odinsea/scripting/player_api.ex
Normal file
File diff suppressed because it is too large
Load Diff
345
lib/odinsea/scripting/portal_manager.ex
Normal file
345
lib/odinsea/scripting/portal_manager.ex
Normal file
@@ -0,0 +1,345 @@
|
||||
defmodule Odinsea.Scripting.PortalManager do
|
||||
@moduledoc """
|
||||
Portal Script Manager for handling scripted portals.
|
||||
|
||||
Portal scripts are triggered when a player enters a portal with a script name.
|
||||
They receive a `pi` (portal interaction) API object that extends PlayerAPI
|
||||
with portal-specific functionality.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Portal scripts must implement the `enter/1` callback:
|
||||
|
||||
defmodule Odinsea.Scripting.Portal.Script_08_xmas_st do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
|
||||
@impl true
|
||||
def enter(pi) do
|
||||
# Portal logic here
|
||||
if PlayerAPI.get_player_stat(pi, "LVL") >= 10 do
|
||||
PlayerAPI.warp(pi, 100000000)
|
||||
:ok
|
||||
else
|
||||
PlayerAPI.player_message(pi, "You must be level 10 to enter.")
|
||||
{:error, :level_too_low}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
## JavaScript Compatibility
|
||||
|
||||
For JavaScript scripts:
|
||||
- `pi` - Portal interaction API
|
||||
- `function enter(pi)` - Entry point
|
||||
|
||||
## Portal API Extensions
|
||||
|
||||
The portal API (`pi`) includes all PlayerAPI functions plus:
|
||||
- `get_portal/0` - Get portal data
|
||||
- `in_free_market/0` - Warp to free market
|
||||
- `in_ardentmill/0` - Warp to crafting town
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, PlayerAPI}
|
||||
|
||||
# ETS table for caching compiled portal scripts
|
||||
@portal_cache :portal_scripts
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type portal_script :: module()
|
||||
@type portal_result :: :ok | {:error, term()}
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the portal script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Executes a portal script when a player enters a scripted portal.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the portal script (e.g., "08_xmas_st")
|
||||
- `client_pid` - Player's client process
|
||||
- `character_id` - Character ID
|
||||
- `portal_data` - Portal information (position, target map, etc.)
|
||||
|
||||
## Returns
|
||||
- `:ok` - Script executed successfully
|
||||
- `{:error, reason}` - Script execution failed or script not found
|
||||
"""
|
||||
@spec execute(String.t(), pid(), integer(), map()) :: portal_result()
|
||||
def execute(script_name, client_pid, character_id, portal_data) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:execute,
|
||||
script_name,
|
||||
client_pid,
|
||||
character_id,
|
||||
portal_data
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a portal script into the cache.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script loaded
|
||||
- `{:error, reason}` - Failed to load
|
||||
"""
|
||||
@spec load_script(String.t()) :: {:ok, module()} | {:error, term()}
|
||||
def load_script(script_name) do
|
||||
GenServer.call(__MODULE__, {:load_script, script_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a cached portal script.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `{:ok, module}` - Script found
|
||||
- `{:error, :not_found}` - Script not cached
|
||||
"""
|
||||
@spec get_script(String.t()) :: {:ok, module()} | {:error, term()}
|
||||
def get_script(script_name) do
|
||||
case :ets.lookup(@portal_cache, script_name) do
|
||||
[{^script_name, module}] -> {:ok, module}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached portal scripts.
|
||||
"""
|
||||
@spec clear_cache() :: :ok
|
||||
def clear_cache() do
|
||||
GenServer.call(__MODULE__, :clear_cache)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available portal scripts.
|
||||
|
||||
## Returns
|
||||
- List of script names
|
||||
"""
|
||||
@spec list_scripts() :: [String.t()]
|
||||
def list_scripts() do
|
||||
Manager.list_scripts(:portal)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a portal script exists.
|
||||
|
||||
## Parameters
|
||||
- `script_name` - Name of the script
|
||||
|
||||
## Returns
|
||||
- `true` - Script exists
|
||||
- `false` - Script does not exist
|
||||
"""
|
||||
@spec script_exists?(String.t()) :: boolean()
|
||||
def script_exists?(script_name) do
|
||||
Manager.script_exists?(:portal, script_name)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS table for caching portal scripts
|
||||
:ets.new(@portal_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Portal Script Manager initialized")
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:execute, script_name, client_pid, character_id, portal_data}, _from, state) do
|
||||
# Get or load the script
|
||||
script_result = case get_script(script_name) do
|
||||
{:ok, module} -> {:ok, module}
|
||||
{:error, :not_found} -> do_load_script(script_name)
|
||||
end
|
||||
|
||||
case script_result do
|
||||
{:ok, script_module} ->
|
||||
# Create portal interaction API
|
||||
pi = create_portal_api(client_pid, character_id, portal_data)
|
||||
|
||||
# Execute the script's enter function
|
||||
result = try do
|
||||
if function_exported?(script_module, :enter, 1) do
|
||||
script_module.enter(pi)
|
||||
else
|
||||
Logger.warning("Portal script #{script_name} missing enter/1 function")
|
||||
{:error, :invalid_script}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Portal script #{script_name} error: #{inspect(e)}")
|
||||
{:error, :script_error}
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("Portal script #{script_name} crashed: #{kind} #{inspect(reason)}")
|
||||
{:error, :script_crash}
|
||||
end
|
||||
|
||||
{:reply, result, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Unhandled portal script #{script_name}: #{inspect(reason)}")
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, script_name}, _from, state) do
|
||||
result = do_load_script(script_name)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_cache, _from, state) do
|
||||
:ets.delete_all_objects(@portal_cache)
|
||||
Logger.info("Portal script cache cleared")
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_load_script(script_name) do
|
||||
case Manager.get_script(:portal, script_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@portal_cache, {script_name, module})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_portal_api(client_pid, character_id, portal_data) do
|
||||
# Create extended PlayerAPI with portal-specific functions
|
||||
base_api = PlayerAPI.new(client_pid, character_id, portal_data.id, nil, nil)
|
||||
|
||||
# Add portal-specific data
|
||||
Map.put(base_api, :__portal_data__, portal_data)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Portal API Extensions (for use in scripts)
|
||||
# ============================================================================
|
||||
|
||||
defmodule PortalAPI do
|
||||
@moduledoc """
|
||||
Portal-specific API extensions.
|
||||
|
||||
These functions are available on the `pi` object passed to portal scripts.
|
||||
"""
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
|
||||
@doc """
|
||||
Gets the portal data.
|
||||
|
||||
## Parameters
|
||||
- `pi` - Portal API struct
|
||||
|
||||
## Returns
|
||||
- Portal data map
|
||||
"""
|
||||
@spec get_portal(PlayerAPI.t()) :: map()
|
||||
def get_portal(%{__portal_data__: data}), do: data
|
||||
def get_portal(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Gets portal position.
|
||||
"""
|
||||
@spec get_position(PlayerAPI.t()) :: {integer(), integer()}
|
||||
def get_position(pi) do
|
||||
case get_portal(pi) do
|
||||
%{x: x, y: y} -> {x, y}
|
||||
_ -> {0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps to Free Market if level >= 15.
|
||||
"""
|
||||
@spec in_free_market(PlayerAPI.t()) :: :ok
|
||||
def in_free_market(pi) do
|
||||
level = PlayerAPI.get_player_stat(pi, "LVL")
|
||||
|
||||
if level >= 15 do
|
||||
# Save return location
|
||||
PlayerAPI.save_location(pi, "FREE_MARKET")
|
||||
PlayerAPI.play_portal_se(pi)
|
||||
PlayerAPI.warp_portal(pi, 910000000, "st00")
|
||||
else
|
||||
PlayerAPI.player_message_type(pi, 5, "You must be level 15 to enter the Free Market.")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Warps to Ardentmill (crafting town) if level >= 10.
|
||||
"""
|
||||
@spec in_ardentmill(PlayerAPI.t()) :: :ok
|
||||
def in_ardentmill(pi) do
|
||||
level = PlayerAPI.get_player_stat(pi, "LVL")
|
||||
|
||||
if level >= 10 do
|
||||
PlayerAPI.save_location(pi, "ARDENTMILL")
|
||||
PlayerAPI.play_portal_se(pi)
|
||||
PlayerAPI.warp_portal(pi, 910001000, "st00")
|
||||
else
|
||||
PlayerAPI.player_message_type(pi, 5, "You must be level 10 to enter the Crafting Town.")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns monster at portal position.
|
||||
"""
|
||||
@spec spawn_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_monster(pi, mob_id) do
|
||||
{x, y} = get_position(pi)
|
||||
PlayerAPI.spawn_monster_pos(pi, mob_id, 1, x, y)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns multiple monsters at portal position.
|
||||
"""
|
||||
@spec spawn_monsters(PlayerAPI.t(), integer(), integer()) :: :ok
|
||||
def spawn_monsters(pi, mob_id, qty) do
|
||||
{x, y} = get_position(pi)
|
||||
PlayerAPI.spawn_monster_pos(pi, mob_id, qty, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
499
lib/odinsea/scripting/reactor_manager.ex
Normal file
499
lib/odinsea/scripting/reactor_manager.ex
Normal file
@@ -0,0 +1,499 @@
|
||||
defmodule Odinsea.Scripting.ReactorManager do
|
||||
@moduledoc """
|
||||
Reactor Script Manager for handling reactor (map object) interactions.
|
||||
|
||||
Reactor scripts are triggered when a player hits/activates a reactor.
|
||||
They receive an `rm` (reactor manager) API object that extends PlayerAPI
|
||||
with reactor-specific functionality like dropping items.
|
||||
|
||||
## Script Interface
|
||||
|
||||
Reactor scripts must implement the `act/1` callback:
|
||||
|
||||
defmodule Odinsea.Scripting.Reactor.Script_1002001 do
|
||||
@behaviour Odinsea.Scripting.Behavior
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
alias Odinsea.Scripting.ReactorManager.ReactorAPI
|
||||
|
||||
@impl true
|
||||
def act(rm) do
|
||||
# Drop items at reactor position
|
||||
ReactorAPI.drop_items(rm, true, 1, 100, 500)
|
||||
|
||||
# Or drop a single item
|
||||
ReactorAPI.drop_single_item(rm, 4000000)
|
||||
end
|
||||
end
|
||||
|
||||
## JavaScript Compatibility
|
||||
|
||||
For JavaScript scripts:
|
||||
- `rm` - Reactor action manager API
|
||||
- `function act()` - Entry point
|
||||
|
||||
## Reactor API Extensions
|
||||
|
||||
The reactor API (`rm`) includes all PlayerAPI functions plus:
|
||||
- `drop_items/5` - Drop items/meso at reactor position
|
||||
- `drop_single_item/2` - Drop a single item
|
||||
- `get_position/1` - Get reactor position
|
||||
- `spawn_zakum/1` - Spawn Zakum boss
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Scripting.{Manager, PlayerAPI}
|
||||
|
||||
# ETS table for caching reactor scripts
|
||||
@reactor_cache :reactor_scripts
|
||||
|
||||
# ETS table for reactor drops
|
||||
@reactor_drops :reactor_drops
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@type reactor_script :: module()
|
||||
@type reactor_result :: :ok | {:error, term()}
|
||||
|
||||
defmodule DropEntry do
|
||||
@moduledoc "Represents a reactor drop entry."
|
||||
|
||||
defstruct [
|
||||
:item_id, # Item ID (0 = meso)
|
||||
:chance, # Drop chance (1 in N)
|
||||
:quest_id # Required quest (-1 = none)
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
item_id: integer(),
|
||||
chance: integer(),
|
||||
quest_id: integer()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the reactor script manager.
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Executes a reactor script when a player activates a reactor.
|
||||
|
||||
## Parameters
|
||||
- `reactor_id` - Reactor template ID
|
||||
- `client_pid` - Player's client process
|
||||
- `character_id` - Character ID
|
||||
- `reactor_instance` - Reactor instance data
|
||||
|
||||
## Returns
|
||||
- `:ok` - Script executed successfully
|
||||
- `{:error, reason}` - Script execution failed
|
||||
"""
|
||||
@spec act(integer(), pid(), integer(), map()) :: reactor_result()
|
||||
def act(reactor_id, client_pid, character_id, reactor_instance) do
|
||||
GenServer.call(__MODULE__, {
|
||||
:act,
|
||||
reactor_id,
|
||||
client_pid,
|
||||
character_id,
|
||||
reactor_instance
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets drops for a reactor.
|
||||
|
||||
## Parameters
|
||||
- `reactor_id` - Reactor template ID
|
||||
|
||||
## Returns
|
||||
- List of DropEntry structs
|
||||
"""
|
||||
@spec get_drops(integer()) :: [DropEntry.t()]
|
||||
def get_drops(reactor_id) do
|
||||
case :ets.lookup(@reactor_drops, reactor_id) do
|
||||
[{^reactor_id, drops}] -> drops
|
||||
[] -> load_drops(reactor_id)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all cached reactor drops.
|
||||
"""
|
||||
@spec clear_drops() :: :ok
|
||||
def clear_drops() do
|
||||
GenServer.call(__MODULE__, :clear_drops)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a reactor script into the cache.
|
||||
"""
|
||||
@spec load_script(integer()) :: {:ok, module()} | {:error, term()}
|
||||
def load_script(reactor_id) do
|
||||
GenServer.call(__MODULE__, {:load_script, reactor_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all available reactor scripts.
|
||||
"""
|
||||
@spec list_scripts() :: [String.t()]
|
||||
def list_scripts() do
|
||||
Manager.list_scripts(:reactor)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@reactor_cache, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
:ets.new(@reactor_drops, [:named_table, :set, :public,
|
||||
read_concurrency: true, write_concurrency: true])
|
||||
|
||||
Logger.info("Reactor Script Manager initialized")
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:act, reactor_id, client_pid, character_id, reactor_instance}, _from, state) do
|
||||
# Get or load the script
|
||||
script_name = to_string(reactor_id)
|
||||
|
||||
script_result = case :ets.lookup(@reactor_cache, reactor_id) do
|
||||
[{^reactor_id, module}] -> {:ok, module}
|
||||
[] -> do_load_script(reactor_id)
|
||||
end
|
||||
|
||||
case script_result do
|
||||
{:ok, script_module} ->
|
||||
# Create reactor action API
|
||||
rm = create_reactor_api(client_pid, character_id, reactor_id, reactor_instance)
|
||||
|
||||
# Execute the script's act function
|
||||
result = try do
|
||||
if function_exported?(script_module, :act, 1) do
|
||||
script_module.act(rm)
|
||||
else
|
||||
Logger.warning("Reactor script #{reactor_id} missing act/1 function")
|
||||
# Execute default drop behavior
|
||||
ReactorAPI.drop_items(rm, false, 0, 0, 0)
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Reactor script #{reactor_id} error: #{inspect(e)}")
|
||||
:ok # Don't error on reactor scripts, just log
|
||||
catch
|
||||
kind, reason ->
|
||||
Logger.error("Reactor script #{reactor_id} crashed: #{kind} #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
{:reply, result, state}
|
||||
|
||||
{:error, _reason} ->
|
||||
# No script found - execute default drop behavior
|
||||
rm = create_reactor_api(client_pid, character_id, reactor_id, reactor_instance)
|
||||
ReactorAPI.drop_items(rm, false, 0, 0, 0)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:load_script, reactor_id}, _from, state) do
|
||||
result = do_load_script(reactor_id)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:clear_drops, _from, state) do
|
||||
:ets.delete_all_objects(@reactor_drops)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp do_load_script(reactor_id) do
|
||||
script_name = to_string(reactor_id)
|
||||
|
||||
case Manager.get_script(:reactor, script_name) do
|
||||
{:ok, module} ->
|
||||
:ets.insert(@reactor_cache, {reactor_id, module})
|
||||
{:ok, module}
|
||||
|
||||
{:error, reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp load_drops(reactor_id) do
|
||||
# TODO: Load from database
|
||||
# For now, return empty list
|
||||
drops = []
|
||||
:ets.insert(@reactor_drops, {reactor_id, drops})
|
||||
drops
|
||||
end
|
||||
|
||||
defp create_reactor_api(client_pid, character_id, reactor_id, reactor_instance) do
|
||||
base_api = PlayerAPI.new(client_pid, character_id, reactor_id, nil, nil)
|
||||
|
||||
Map.merge(base_api, %{
|
||||
__reactor_instance__: reactor_instance,
|
||||
__reactor_id__: reactor_id
|
||||
})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Reactor API Extensions (for use in scripts)
|
||||
# ============================================================================
|
||||
|
||||
defmodule ReactorAPI do
|
||||
@moduledoc """
|
||||
Reactor-specific API extensions.
|
||||
|
||||
These functions are available on the `rm` object passed to reactor scripts.
|
||||
"""
|
||||
|
||||
alias Odinsea.Scripting.PlayerAPI
|
||||
alias Odinsea.Scripting.ReactorManager.DropEntry
|
||||
|
||||
@doc """
|
||||
Gets the reactor instance data.
|
||||
"""
|
||||
@spec get_reactor(PlayerAPI.t()) :: map()
|
||||
def get_reactor(%{__reactor_instance__: data}), do: data
|
||||
def get_reactor(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Gets reactor position.
|
||||
"""
|
||||
@spec get_position(PlayerAPI.t()) :: {integer(), integer()}
|
||||
def get_position(rm) do
|
||||
case get_reactor(rm) do
|
||||
%{x: x, y: y} -> {x, y - 10} # Slightly above for drops
|
||||
_ -> {0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets reactor ID.
|
||||
"""
|
||||
@spec get_reactor_id(PlayerAPI.t()) :: integer()
|
||||
def get_reactor_id(%{__reactor_id__: id}), do: id
|
||||
def get_reactor_id(_), do: 0
|
||||
|
||||
@doc """
|
||||
Drops items from reactor.
|
||||
|
||||
## Parameters
|
||||
- `rm` - Reactor API
|
||||
- `meso` - Whether to drop meso
|
||||
- `meso_chance` - Chance for meso (1 in N)
|
||||
- `min_meso` - Minimum meso amount
|
||||
- `max_meso` - Maximum meso amount
|
||||
- `min_items` - Minimum items to drop
|
||||
"""
|
||||
@spec drop_items(PlayerAPI.t(), boolean(), integer(), integer(), integer(), integer()) :: :ok
|
||||
def drop_items(rm, meso \\ false, meso_chance \\ 0, min_meso \\ 0, max_meso \\ 0, min_items \\ 0) do
|
||||
reactor_id = get_reactor_id(rm)
|
||||
chances = Odinsea.Scripting.ReactorManager.get_drops(reactor_id)
|
||||
|
||||
# Filter drops by chance
|
||||
items = filter_drops(chances, rm)
|
||||
|
||||
# Add meso if enabled
|
||||
items = if meso && :rand.uniform(meso_chance) == 1 do
|
||||
[%DropEntry{item_id: 0, chance: meso_chance, quest_id: -1} | items]
|
||||
else
|
||||
items
|
||||
end
|
||||
|
||||
# Pad with meso if needed
|
||||
items = if length(items) < min_items do
|
||||
pad_items(items, min_items, meso_chance)
|
||||
else
|
||||
items
|
||||
end
|
||||
|
||||
# Calculate drop position
|
||||
{base_x, y} = get_position(rm)
|
||||
count = length(items)
|
||||
start_x = base_x - (12 * count)
|
||||
|
||||
# Drop items
|
||||
Enum.each(Enum.with_index(items), fn {drop, idx} ->
|
||||
x = start_x + (idx * 25)
|
||||
|
||||
if drop.item_id == 0 do
|
||||
# Meso drop
|
||||
amount = :rand.uniform(max_meso - min_meso) + min_meso
|
||||
drop_meso(rm, amount, {x, y})
|
||||
else
|
||||
# Item drop
|
||||
drop_item(rm, drop.item_id, {x, y}, drop.quest_id)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Drops a single item at reactor position.
|
||||
"""
|
||||
@spec drop_single_item(PlayerAPI.t(), integer()) :: :ok
|
||||
def drop_single_item(rm, item_id) do
|
||||
pos = get_position(rm)
|
||||
drop_item(rm, item_id, pos, -1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns Zakum at reactor position.
|
||||
"""
|
||||
@spec spawn_zakum(PlayerAPI.t()) :: :ok
|
||||
def spawn_zakum(rm) do
|
||||
{x, y} = get_position(rm)
|
||||
Logger.debug("Spawn Zakum at (#{x}, #{y})")
|
||||
# TODO: Spawn Zakum
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a fake (non-aggro) monster at reactor position.
|
||||
"""
|
||||
@spec spawn_fake_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_fake_monster(rm, mob_id) do
|
||||
spawn_fake_monster_qty(rm, mob_id, 1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns multiple fake monsters at reactor position.
|
||||
"""
|
||||
@spec spawn_fake_monster_qty(PlayerAPI.t(), integer(), integer()) :: :ok
|
||||
def spawn_fake_monster_qty(rm, mob_id, qty) do
|
||||
{x, y} = get_position(rm)
|
||||
Logger.debug("Spawn fake monster #{mob_id} x#{qty} at (#{x}, #{y})")
|
||||
# TODO: Spawn fake monsters
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns NPC at reactor position.
|
||||
"""
|
||||
@spec spawn_npc(PlayerAPI.t(), integer()) :: :ok
|
||||
def spawn_npc(rm, npc_id) do
|
||||
{x, y} = get_position(rm)
|
||||
PlayerAPI.spawn_npc_pos(rm, npc_id, x, y)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kills all monsters on the map.
|
||||
"""
|
||||
@spec kill_all(PlayerAPI.t()) :: :ok
|
||||
def kill_all(rm) do
|
||||
PlayerAPI.kill_all_mob(rm)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Kills a specific monster.
|
||||
"""
|
||||
@spec kill_monster(PlayerAPI.t(), integer()) :: :ok
|
||||
def kill_monster(rm, mob_id) do
|
||||
PlayerAPI.kill_mob(rm, mob_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Dispels all monsters (CPQ guardian effect).
|
||||
"""
|
||||
@spec dispel_all_monsters(PlayerAPI.t(), integer()) :: :ok
|
||||
def dispel_all_monsters(rm, _num) do
|
||||
# TODO: Dispel monsters
|
||||
Logger.debug("Dispel all monsters")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Performs harvesting (profession gathering).
|
||||
"""
|
||||
@spec do_harvest(PlayerAPI.t()) :: :ok
|
||||
def do_harvest(rm) do
|
||||
# TODO: Implement harvesting logic
|
||||
Logger.debug("Harvesting at reactor")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cancels harvesting.
|
||||
"""
|
||||
@spec cancel_harvest(PlayerAPI.t(), boolean()) :: :ok
|
||||
def cancel_harvest(_rm, _success) do
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp filter_drops(chances, rm) do
|
||||
Enum.filter(chances, fn drop ->
|
||||
passed_chance = :rand.uniform(drop.chance) == 1
|
||||
passed_quest = should_drop_quest_item(drop.quest_id, rm)
|
||||
passed_chance && passed_quest
|
||||
end)
|
||||
end
|
||||
|
||||
defp should_drop_quest_item(quest_id, _rm) when quest_id <= 0, do: true
|
||||
defp should_drop_quest_item(quest_id, rm) do
|
||||
# TODO: Check if any player on map has quest active
|
||||
# For now, return true
|
||||
true
|
||||
end
|
||||
|
||||
defp pad_items(items, min_items, meso_chance) when length(items) < min_items do
|
||||
pad_items(
|
||||
[%DropEntry{item_id: 0, chance: meso_chance, quest_id: -1} | items],
|
||||
min_items,
|
||||
meso_chance
|
||||
)
|
||||
end
|
||||
defp pad_items(items, _min, _chance), do: items
|
||||
|
||||
defp drop_meso(rm, amount, position) do
|
||||
Logger.debug("Drop #{amount} meso at #{inspect(position)}")
|
||||
# TODO: Spawn meso drop
|
||||
:ok
|
||||
end
|
||||
|
||||
defp drop_item(rm, item_id, position, quest_id) do
|
||||
owner = get_drop_owner(quest_id, rm)
|
||||
Logger.debug("Drop item #{item_id} at #{inspect(position)}, owner: #{inspect(owner)}")
|
||||
# TODO: Spawn item drop
|
||||
:ok
|
||||
end
|
||||
|
||||
defp get_drop_owner(quest_id, rm) when quest_id <= 0 do
|
||||
# Return triggering player
|
||||
rm.character_id
|
||||
end
|
||||
defp get_drop_owner(_quest_id, rm) do
|
||||
# TODO: Find player who needs quest item
|
||||
rm.character_id
|
||||
end
|
||||
end
|
||||
end
|
||||
44
lib/odinsea/scripting/supervisor.ex
Normal file
44
lib/odinsea/scripting/supervisor.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule Odinsea.Scripting.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for the Scripting system.
|
||||
|
||||
Manages all scripting-related processes:
|
||||
- Script Manager (base script loading and caching)
|
||||
- NPC Script Manager (NPC conversations)
|
||||
- Portal Script Manager (portal scripts)
|
||||
- Reactor Script Manager (reactor scripts)
|
||||
- Event Script Manager (event/party quest scripts)
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
@doc """
|
||||
Starts the scripting supervisor.
|
||||
"""
|
||||
@spec start_link(keyword()) :: Supervisor.on_start()
|
||||
def start_link(init_arg) do
|
||||
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
# Base script manager - handles loading and caching
|
||||
Odinsea.Scripting.Manager,
|
||||
|
||||
# NPC Script Manager - handles NPC conversations
|
||||
Odinsea.Scripting.NPCManager,
|
||||
|
||||
# Portal Script Manager - handles scripted portals
|
||||
Odinsea.Scripting.PortalManager,
|
||||
|
||||
# Reactor Script Manager - handles reactor interactions
|
||||
Odinsea.Scripting.ReactorManager,
|
||||
|
||||
# Event Script Manager - handles events and party quests
|
||||
Odinsea.Scripting.EventManager
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
186
lib/odinsea/shop/cash_item.ex
Normal file
186
lib/odinsea/shop/cash_item.ex
Normal file
@@ -0,0 +1,186 @@
|
||||
defmodule Odinsea.Shop.CashItem do
|
||||
@moduledoc """
|
||||
Cash Shop Item struct and utilities.
|
||||
|
||||
Represents an item available for purchase in the Cash Shop.
|
||||
Ported from server/CashItemInfo.java and server/cash/CashCommodity.java
|
||||
"""
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
sn: integer(),
|
||||
item_id: integer(),
|
||||
price: integer(),
|
||||
count: integer(),
|
||||
period: integer(),
|
||||
gender: integer(),
|
||||
on_sale: boolean(),
|
||||
class: integer(),
|
||||
priority: integer(),
|
||||
is_package: boolean(),
|
||||
meso_price: integer(),
|
||||
bonus: integer(),
|
||||
for_premium_user: integer(),
|
||||
limit: integer(),
|
||||
extra_flags: integer()
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:sn,
|
||||
:item_id,
|
||||
:price,
|
||||
:count,
|
||||
:period,
|
||||
:gender,
|
||||
:on_sale,
|
||||
:class,
|
||||
:priority,
|
||||
:is_package,
|
||||
:meso_price,
|
||||
:bonus,
|
||||
:for_premium_user,
|
||||
:limit,
|
||||
:extra_flags
|
||||
]
|
||||
|
||||
@doc """
|
||||
Creates a new CashItem struct from parsed data.
|
||||
"""
|
||||
@spec new(map()) :: t()
|
||||
def new(attrs) do
|
||||
%__MODULE__{
|
||||
sn: Map.get(attrs, :sn, 0),
|
||||
item_id: Map.get(attrs, :item_id, 0),
|
||||
price: Map.get(attrs, :price, 0),
|
||||
count: Map.get(attrs, :count, 1),
|
||||
period: Map.get(attrs, :period, 0),
|
||||
gender: Map.get(attrs, :gender, 2),
|
||||
on_sale: Map.get(attrs, :on_sale, false),
|
||||
class: Map.get(attrs, :class, 0),
|
||||
priority: Map.get(attrs, :priority, 0),
|
||||
is_package: Map.get(attrs, :is_package, false),
|
||||
meso_price: Map.get(attrs, :meso_price, 0),
|
||||
bonus: Map.get(attrs, :bonus, 0),
|
||||
for_premium_user: Map.get(attrs, :for_premium_user, 0),
|
||||
limit: Map.get(attrs, :limit, 0),
|
||||
extra_flags: Map.get(attrs, :extra_flags, 0)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the item gender matches the player's gender.
|
||||
Gender: 0 = male, 1 = female, 2 = both
|
||||
"""
|
||||
@spec gender_matches?(t(), integer()) :: boolean()
|
||||
def gender_matches?(%__MODULE__{gender: 2}, _player_gender), do: true
|
||||
def gender_matches?(%__MODULE__{gender: gender}, player_gender), do: gender == player_gender
|
||||
|
||||
@doc """
|
||||
Calculates the flags value for packet encoding.
|
||||
This follows the Java CashCommodity flag calculation.
|
||||
"""
|
||||
@spec calculate_flags(t()) :: integer()
|
||||
def calculate_flags(item) do
|
||||
flags = item.extra_flags || 0
|
||||
|
||||
flags = if item.item_id > 0, do: Bitwise.bor(flags, 0x1), else: flags
|
||||
flags = if item.count > 0, do: Bitwise.bor(flags, 0x2), else: flags
|
||||
flags = if item.price > 0, do: Bitwise.bor(flags, 0x4), else: flags
|
||||
flags = if item.bonus > 0, do: Bitwise.bor(flags, 0x8), else: flags
|
||||
flags = if item.priority >= 0, do: Bitwise.bor(flags, 0x10), else: flags
|
||||
flags = if item.period > 0, do: Bitwise.bor(flags, 0x20), else: flags
|
||||
# 0x40 = nMaplePoint (not used)
|
||||
flags = if item.meso_price > 0, do: Bitwise.bor(flags, 0x80), else: flags
|
||||
flags = if item.for_premium_user > 0, do: Bitwise.bor(flags, 0x100), else: flags
|
||||
flags = if item.gender >= 0, do: Bitwise.bor(flags, 0x200), else: flags
|
||||
flags = if item.on_sale, do: Bitwise.bor(flags, 0x400), else: flags
|
||||
flags = if item.class >= -1 && item.class <= 3, do: Bitwise.bor(flags, 0x800), else: flags
|
||||
flags = if item.limit > 0, do: Bitwise.bor(flags, 0x1000), else: flags
|
||||
# 0x2000, 0x4000, 0x8000 = nPbCash, nPbPoint, nPbGift (not used)
|
||||
flags = if item.is_package, do: Bitwise.bor(flags, 0x40000), else: flags
|
||||
# 0x80000, 0x100000 = term start/end (not used)
|
||||
|
||||
flags
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a cash item (premium currency item).
|
||||
"""
|
||||
@spec cash_item?(integer()) :: boolean()
|
||||
def cash_item?(item_id) do
|
||||
# Cash items typically have IDs in certain ranges
|
||||
# This is a simplified check - full implementation would check WZ data
|
||||
item_id >= 500_000 && item_id < 600_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this item is a pet.
|
||||
"""
|
||||
@spec pet?(t()) :: boolean()
|
||||
def pet?(%__MODULE__{item_id: item_id}) do
|
||||
item_id >= 5_000_000 && item_id < 5_100_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this is a permanent pet.
|
||||
"""
|
||||
@spec permanent_pet?(t()) :: boolean()
|
||||
def permanent_pet?(%__MODULE__{item_id: item_id}) do
|
||||
item_id >= 5_000_100 && item_id < 5_000_200
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the effective period for this item.
|
||||
Returns period in days, or special values for permanent items.
|
||||
"""
|
||||
@spec effective_period(t()) :: integer()
|
||||
def effective_period(%__MODULE__{period: period} = item) do
|
||||
cond do
|
||||
# Permanent pets have special handling
|
||||
permanent_pet?(item) -> 20_000
|
||||
# Default period for non-equip cash items that aren't permanent
|
||||
period <= 0 && !equip_item?(item) -> 90
|
||||
true -> period
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if this item is equipment.
|
||||
"""
|
||||
@spec equip_item?(t()) :: boolean()
|
||||
def equip_item?(%__MODULE__{item_id: item_id}) do
|
||||
item_id >= 1_000_000
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the expiration timestamp for this item.
|
||||
"""
|
||||
@spec expiration_time(t()) :: integer()
|
||||
def expiration_time(item) do
|
||||
period = effective_period(item)
|
||||
|
||||
if period > 0 do
|
||||
Odinsea.now() + period * 24 * 60 * 60 * 1000
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies modified item info (from cashshop_modified_items table).
|
||||
"""
|
||||
@spec apply_mods(t(), map()) :: t()
|
||||
def apply_mods(item, mods) do
|
||||
%__MODULE__{
|
||||
item
|
||||
| item_id: Map.get(mods, :item_id, item.item_id),
|
||||
price: Map.get(mods, :price, item.price),
|
||||
count: Map.get(mods, :count, item.count),
|
||||
period: Map.get(mods, :period, item.period),
|
||||
gender: Map.get(mods, :gender, item.gender),
|
||||
on_sale: Map.get(mods, :on_sale, item.on_sale),
|
||||
class: Map.get(mods, :class, item.class),
|
||||
priority: Map.get(mods, :priority, item.priority),
|
||||
is_package: Map.get(mods, :is_package, item.is_package)
|
||||
}
|
||||
end
|
||||
end
|
||||
367
lib/odinsea/shop/cash_item_factory.ex
Normal file
367
lib/odinsea/shop/cash_item_factory.ex
Normal file
@@ -0,0 +1,367 @@
|
||||
defmodule Odinsea.Shop.CashItemFactory do
|
||||
@moduledoc """
|
||||
Cash Item Factory - loads and caches cash shop item data.
|
||||
|
||||
This module loads cash shop item data from JSON files and caches it in ETS
|
||||
for fast lookups. Ported from server/CashItemFactory.java.
|
||||
|
||||
Data sources:
|
||||
- cash_items.json: Base item definitions (from WZ Commodity.img)
|
||||
- cash_packages.json: Package item definitions
|
||||
- cash_mods.json: Modified item info (from database)
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Shop.CashItem
|
||||
|
||||
# ETS table names
|
||||
@item_cache :odinsea_cash_items
|
||||
@package_cache :odinsea_cash_packages
|
||||
@category_cache :odinsea_cash_categories
|
||||
|
||||
# Data file paths (relative to priv directory)
|
||||
@items_file "data/cash_items.json"
|
||||
@packages_file "data/cash_packages.json"
|
||||
@categories_file "data/cash_categories.json"
|
||||
@mods_file "data/cash_mods.json"
|
||||
|
||||
# Best items (featured items for the main page)
|
||||
@best_items [
|
||||
100_030_55,
|
||||
100_030_90,
|
||||
101_034_64,
|
||||
100_029_60,
|
||||
101_033_63
|
||||
]
|
||||
|
||||
## Public API
|
||||
|
||||
@doc "Starts the CashItemFactory GenServer"
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "Gets a cash item by SN (serial number)"
|
||||
@spec get_item(integer()) :: CashItem.t() | nil
|
||||
def get_item(sn) do
|
||||
case :ets.lookup(@item_cache, sn) do
|
||||
[{^sn, item}] -> item
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets a simple item by SN (without modification check)"
|
||||
@spec get_simple_item(integer()) :: CashItem.t() | nil
|
||||
def get_simple_item(sn) do
|
||||
get_item(sn)
|
||||
end
|
||||
|
||||
@doc "Gets all items in a package by item ID"
|
||||
@spec get_package_items(integer()) :: [integer()] | nil
|
||||
def get_package_items(item_id) do
|
||||
case :ets.lookup(@package_cache, item_id) do
|
||||
[{^item_id, items}] -> items
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Gets all cash items"
|
||||
@spec get_all_items() :: [CashItem.t()]
|
||||
def get_all_items do
|
||||
:ets.select(@item_cache, [{{:_, :"$1"}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Gets items that are currently on sale"
|
||||
@spec get_sale_items() :: [CashItem.t()]
|
||||
def get_sale_items do
|
||||
get_all_items()
|
||||
|> Enum.filter(& &1.on_sale)
|
||||
end
|
||||
|
||||
@doc "Gets items by category"
|
||||
@spec get_items_by_category(integer()) :: [CashItem.t()]
|
||||
def get_items_by_category(category_id) do
|
||||
# Filter items by category - simplified implementation
|
||||
# Full implementation would check category mappings
|
||||
get_all_items()
|
||||
|> Enum.filter(fn item ->
|
||||
# Check if item belongs to category based on item_id
|
||||
# This is a simplified check
|
||||
case category_id do
|
||||
1 -> item.item_id >= 5_000_000 && item.item_id < 5_010_000
|
||||
2 -> item.item_id >= 5_100_000 && item.item_id < 5_110_000
|
||||
3 -> item.item_id >= 1_700_000 && item.item_id < 1_800_000
|
||||
_ -> true
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Gets the best/featured items"
|
||||
@spec get_best_items() :: [integer()]
|
||||
def get_best_items do
|
||||
@best_items
|
||||
end
|
||||
|
||||
@doc "Gets all categories"
|
||||
@spec get_categories() :: [map()]
|
||||
def get_categories do
|
||||
:ets.select(@category_cache, [{{:_, :"$1"}, [], [:"$1"]}])
|
||||
end
|
||||
|
||||
@doc "Gets a category by ID"
|
||||
@spec get_category(integer()) :: map() | nil
|
||||
def get_category(category_id) do
|
||||
case :ets.lookup(@category_cache, category_id) do
|
||||
[{^category_id, cat}] -> cat
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Checks if an item is blocked from cash shop purchase"
|
||||
@spec blocked?(integer()) :: boolean()
|
||||
def blocked?(item_id) do
|
||||
# List of blocked item IDs (hacks, exploits, etc.)
|
||||
blocked_ids = [
|
||||
# Add specific blocked item IDs here
|
||||
]
|
||||
|
||||
item_id in blocked_ids
|
||||
end
|
||||
|
||||
@doc "Checks if an item should be ignored (weapon skins, etc.)"
|
||||
@spec ignore_weapon?(integer()) :: boolean()
|
||||
def ignore_weapon?(item_id) do
|
||||
# Ignore certain weapon skin items
|
||||
false
|
||||
end
|
||||
|
||||
@doc "Reloads cash item data from files"
|
||||
@spec reload() :: :ok
|
||||
def reload do
|
||||
GenServer.call(__MODULE__, :reload, :infinity)
|
||||
end
|
||||
|
||||
@doc "Generates random featured items"
|
||||
@spec generate_featured() :: [integer()]
|
||||
def generate_featured do
|
||||
# Get all on-sale items
|
||||
sale_items =
|
||||
get_all_items()
|
||||
|> Enum.filter(& &1.on_sale)
|
||||
|> Enum.map(& &1.item_id)
|
||||
|
||||
# Return random selection or defaults
|
||||
if length(sale_items) > 10 do
|
||||
sale_items
|
||||
|> Enum.shuffle()
|
||||
|> Enum.take(10)
|
||||
else
|
||||
@best_items
|
||||
end
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(@item_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@package_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@category_cache, [:set, :public, :named_table, read_concurrency: true])
|
||||
|
||||
# Load data
|
||||
load_cash_data()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:reload, _from, state) do
|
||||
Logger.info("Reloading cash shop data...")
|
||||
load_cash_data()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp load_cash_data do
|
||||
priv_dir = :code.priv_dir(:odinsea) |> to_string()
|
||||
|
||||
load_categories(Path.join(priv_dir, @categories_file))
|
||||
load_items(Path.join(priv_dir, @items_file))
|
||||
load_packages(Path.join(priv_dir, @packages_file))
|
||||
load_modifications(Path.join(priv_dir, @mods_file))
|
||||
|
||||
item_count = :ets.info(@item_cache, :size)
|
||||
Logger.info("Loaded #{item_count} cash shop items")
|
||||
end
|
||||
|
||||
defp load_categories(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, categories} when is_list(categories) ->
|
||||
Enum.each(categories, fn cat ->
|
||||
id = Map.get(cat, :id)
|
||||
if id, do: :ets.insert(@category_cache, {id, cat})
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse categories JSON: #{inspect(reason)}")
|
||||
create_fallback_categories()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.debug("Categories file not found: #{file_path}, using fallback")
|
||||
create_fallback_categories()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read categories: #{inspect(reason)}")
|
||||
create_fallback_categories()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_items(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, items} when is_list(items) ->
|
||||
Enum.each(items, fn item_data ->
|
||||
item = CashItem.new(item_data)
|
||||
|
||||
if item.sn > 0 do
|
||||
:ets.insert(@item_cache, {item.sn, item})
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse cash items JSON: #{inspect(reason)}")
|
||||
create_fallback_items()
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.warn("Cash items file not found: #{file_path}, using fallback data")
|
||||
create_fallback_items()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read cash items: #{inspect(reason)}")
|
||||
create_fallback_items()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_packages(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, packages} when is_list(packages) ->
|
||||
Enum.each(packages, fn pkg ->
|
||||
item_id = Map.get(pkg, :item_id)
|
||||
items = Map.get(pkg, :items, [])
|
||||
|
||||
if item_id do
|
||||
:ets.insert(@package_cache, {item_id, items})
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse packages JSON: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.debug("Packages file not found: #{file_path}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read packages: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp load_modifications(file_path) do
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
case Jason.decode(content, keys: :atoms) do
|
||||
{:ok, mods} when is_list(mods) ->
|
||||
Enum.each(mods, fn mod ->
|
||||
sn = Map.get(mod, :sn)
|
||||
|
||||
if sn do
|
||||
# Get existing item and apply modifications
|
||||
case :ets.lookup(@item_cache, sn) do
|
||||
[{^sn, item}] ->
|
||||
modified = CashItem.apply_mods(item, mod)
|
||||
:ets.insert(@item_cache, {sn, modified})
|
||||
|
||||
[] ->
|
||||
# Create new item from modification data
|
||||
item = CashItem.new(mod)
|
||||
:ets.insert(@item_cache, {sn, item})
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warn("Failed to parse mods JSON: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
Logger.debug("Modifications file not found: #{file_path}")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to read modifications: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
# Fallback data for basic testing
|
||||
defp create_fallback_categories do
|
||||
categories = [
|
||||
%{id: 1, name: "Pets", category: 1, sub_category: 0, discount_rate: 0},
|
||||
%{id: 2, name: "Pet Food", category: 2, sub_category: 0, discount_rate: 0},
|
||||
%{id: 3, name: "Weapons", category: 3, sub_category: 0, discount_rate: 0},
|
||||
%{id: 4, name: "Equipment", category: 4, sub_category: 0, discount_rate: 0},
|
||||
%{id: 5, name: "Effects", category: 5, sub_category: 0, discount_rate: 0}
|
||||
]
|
||||
|
||||
Enum.each(categories, fn cat ->
|
||||
:ets.insert(@category_cache, {cat.id, cat})
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_fallback_items do
|
||||
# Basic cash items for testing
|
||||
items = [
|
||||
%{
|
||||
sn: 1_000_000,
|
||||
item_id: 5_000_000,
|
||||
price: 9_000,
|
||||
count: 1,
|
||||
period: 90,
|
||||
gender: 2,
|
||||
on_sale: true
|
||||
},
|
||||
%{
|
||||
sn: 1_000_001,
|
||||
item_id: 5_000_001,
|
||||
price: 9_000,
|
||||
count: 1,
|
||||
period: 90,
|
||||
gender: 2,
|
||||
on_sale: true
|
||||
},
|
||||
%{
|
||||
sn: 1_000_002,
|
||||
item_id: 5_001_000,
|
||||
price: 2_400,
|
||||
count: 1,
|
||||
period: 0,
|
||||
gender: 2,
|
||||
on_sale: true
|
||||
}
|
||||
]
|
||||
|
||||
Enum.each(items, fn item_data ->
|
||||
item = CashItem.new(item_data)
|
||||
:ets.insert(@item_cache, {item.sn, item})
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,12 @@
|
||||
defmodule Odinsea.Shop.Client do
|
||||
@moduledoc """
|
||||
Client connection handler for the cash shop server.
|
||||
|
||||
Handles:
|
||||
- Cash shop operations (buy, gift, wishlist, etc.)
|
||||
- MTS (Maple Trading System) operations
|
||||
- Coupon redemption
|
||||
- Inventory management
|
||||
"""
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
@@ -8,8 +14,19 @@ defmodule Odinsea.Shop.Client do
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Shop.{Operation, MTS, Packets}
|
||||
alias Odinsea.Database.Context
|
||||
|
||||
defstruct [:socket, :ip, :state, :character_id, :account_id]
|
||||
defstruct [
|
||||
:socket,
|
||||
:ip,
|
||||
:state,
|
||||
:character_id,
|
||||
:account_id,
|
||||
:character,
|
||||
:account
|
||||
]
|
||||
|
||||
def start_link(socket) do
|
||||
GenServer.start_link(__MODULE__, socket)
|
||||
@@ -27,7 +44,9 @@ defmodule Odinsea.Shop.Client do
|
||||
ip: ip_string,
|
||||
state: :connected,
|
||||
character_id: nil,
|
||||
account_id: nil
|
||||
account_id: nil,
|
||||
character: nil,
|
||||
account: nil
|
||||
}
|
||||
|
||||
send(self(), :receive)
|
||||
@@ -61,6 +80,10 @@ defmodule Odinsea.Shop.Client do
|
||||
:ok
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Packet Handling
|
||||
# ==============================================================================
|
||||
|
||||
defp handle_packet(data, state) do
|
||||
packet = In.new(data)
|
||||
|
||||
@@ -75,11 +98,98 @@ defmodule Odinsea.Shop.Client do
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch_packet(_opcode, _packet, state) do
|
||||
# TODO: Implement cash shop packet handlers
|
||||
state
|
||||
defp dispatch_packet(opcode, packet, state) do
|
||||
cond do
|
||||
opcode == Opcodes.cp_player_loggedin() ->
|
||||
handle_migrate_in(packet, state)
|
||||
|
||||
opcode == Opcodes.cp_cash_shop_update() ->
|
||||
# Cash shop operations
|
||||
handle_cash_shop_operation(packet, state)
|
||||
|
||||
opcode == Opcodes.cp_mts_operation() ->
|
||||
# MTS operations
|
||||
handle_mts_operation(packet, state)
|
||||
|
||||
opcode == Opcodes.cp_alive_ack() ->
|
||||
# Ping response - ignore
|
||||
state
|
||||
|
||||
true ->
|
||||
Logger.debug("Unhandled cash shop opcode: 0x#{Integer.to_string(opcode, 16)}")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Migrate In Handler
|
||||
# ==============================================================================
|
||||
|
||||
defp handle_migrate_in(packet, state) do
|
||||
{char_id, packet} = In.decode_int(packet)
|
||||
{_client_ip, _packet} = In.decode_string(packet) # Skip client IP
|
||||
|
||||
Logger.info("Cash shop migrate in for character #{char_id}")
|
||||
|
||||
# Load character and account
|
||||
case Context.get_character(char_id) do
|
||||
nil ->
|
||||
Logger.error("Character #{char_id} not found")
|
||||
:gen_tcp.close(state.socket)
|
||||
state
|
||||
|
||||
character ->
|
||||
case Context.get_account(character.account_id) do
|
||||
nil ->
|
||||
Logger.error("Account #{character.account_id} not found")
|
||||
:gen_tcp.close(state.socket)
|
||||
state
|
||||
|
||||
account ->
|
||||
# Load gifts
|
||||
gifts = Context.load_gifts(character.id)
|
||||
character = %{character | cash_inventory: gifts ++ (character.cash_inventory || [])}
|
||||
|
||||
# Send cash shop setup
|
||||
setup_packet = Packets.set_cash_shop(character)
|
||||
:gen_tcp.send(state.socket, setup_packet)
|
||||
|
||||
# Send initial update
|
||||
Operation.cs_update(state.socket, character)
|
||||
|
||||
%{state |
|
||||
state: :in_cash_shop,
|
||||
character_id: char_id,
|
||||
account_id: account.id,
|
||||
character: character,
|
||||
account: account
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Cash Shop Operation Handler
|
||||
# ==============================================================================
|
||||
|
||||
defp handle_cash_shop_operation(packet, state) do
|
||||
# Delegate to Operation module
|
||||
Operation.handle(packet, state)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# MTS Operation Handler
|
||||
# ==============================================================================
|
||||
|
||||
defp handle_mts_operation(packet, state) do
|
||||
# Delegate to MTS module
|
||||
MTS.handle(packet, state)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Utility Functions
|
||||
# ==============================================================================
|
||||
|
||||
defp format_ip({a, b, c, d}) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
782
lib/odinsea/shop/mts.ex
Normal file
782
lib/odinsea/shop/mts.ex
Normal file
@@ -0,0 +1,782 @@
|
||||
defmodule Odinsea.Shop.MTS do
|
||||
@moduledoc """
|
||||
Maple Trading System (MTS) implementation.
|
||||
|
||||
The MTS allows players to:
|
||||
- List items for sale (buy now)
|
||||
- Purchase items from other players
|
||||
- Search for items
|
||||
- Manage their MTS cart
|
||||
|
||||
Ported from handling/cashshop/handler/MTSOperation.java
|
||||
and server/MTSStorage.java / server/MTSCart.java
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Inventory
|
||||
|
||||
# MTS opcodes
|
||||
@mts_sell 2
|
||||
@mts_page 5
|
||||
@mts_search 6
|
||||
@mts_cancel 7
|
||||
@mts_transfer 8
|
||||
@mts_add_cart 9
|
||||
@mts_del_cart 10
|
||||
@mts_buy_now 16
|
||||
@mts_buy_cart 17
|
||||
|
||||
# MTS Constants
|
||||
@min_price 100
|
||||
@mts_meso 5000
|
||||
@listing_duration_days 7
|
||||
|
||||
# ETS tables
|
||||
@mts_items :odinsea_mts_items
|
||||
@mts_carts :odinsea_mts_carts
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:item,
|
||||
:price,
|
||||
:seller_id,
|
||||
:seller_name,
|
||||
:expiration,
|
||||
:buyer_id
|
||||
]
|
||||
|
||||
## Public API
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets or creates a cart for a character.
|
||||
"""
|
||||
@spec get_cart(integer()) :: map()
|
||||
def get_cart(character_id) do
|
||||
case :ets.lookup(@mts_carts, character_id) do
|
||||
[{^character_id, cart}] ->
|
||||
cart
|
||||
|
||||
[] ->
|
||||
cart = %{
|
||||
character_id: character_id,
|
||||
cart: [],
|
||||
inventory: [],
|
||||
not_yet_sold: [],
|
||||
owed_nx: 0,
|
||||
tab: 0,
|
||||
page: 0,
|
||||
type: 0,
|
||||
current_view: []
|
||||
}
|
||||
|
||||
:ets.insert(@mts_carts, {character_id, cart})
|
||||
cart
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates cart view settings.
|
||||
"""
|
||||
@spec change_cart_info(integer(), integer(), integer(), integer()) :: :ok
|
||||
def change_cart_info(character_id, tab, page, type) do
|
||||
cart = get_cart(character_id)
|
||||
|
||||
new_cart = %{
|
||||
cart
|
||||
| tab: tab,
|
||||
page: page,
|
||||
type: type
|
||||
}
|
||||
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates current view (search results).
|
||||
"""
|
||||
@spec change_current_view(integer(), [map()]) :: :ok
|
||||
def change_current_view(character_id, items) do
|
||||
cart = get_cart(character_id)
|
||||
new_cart = %{cart | current_view: items}
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists an item for sale on the MTS.
|
||||
"""
|
||||
@spec list_item(integer(), map(), integer(), String.t()) ::
|
||||
{:ok, integer()} | {:error, atom()}
|
||||
def list_item(seller_id, item, price, seller_name) do
|
||||
if price < @min_price do
|
||||
{:error, :price_too_low}
|
||||
else
|
||||
expiration = Odinsea.now() + @listing_duration_days * 24 * 60 * 60 * 1000
|
||||
|
||||
listing = %__MODULE__{
|
||||
id: generate_listing_id(),
|
||||
item: item,
|
||||
price: price,
|
||||
seller_id: seller_id,
|
||||
seller_name: seller_name,
|
||||
expiration: expiration,
|
||||
buyer_id: nil
|
||||
}
|
||||
|
||||
:ets.insert(@mts_items, {listing.id, listing})
|
||||
|
||||
# Add to seller's "not yet sold" list
|
||||
cart = get_cart(seller_id)
|
||||
new_not_yet_sold = [listing.id | cart.not_yet_sold]
|
||||
new_cart = %{cart | not_yet_sold: new_not_yet_sold}
|
||||
:ets.insert(@mts_carts, {seller_id, new_cart})
|
||||
|
||||
{:ok, listing.id}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single MTS item by ID.
|
||||
"""
|
||||
@spec get_item(integer()) :: map() | nil
|
||||
def get_item(id) do
|
||||
case :ets.lookup(@mts_items, id) do
|
||||
[{^id, item}] -> item
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes an item from the MTS.
|
||||
Returns the item to the seller's transfer inventory if canceling.
|
||||
"""
|
||||
@spec remove_item(integer(), integer(), boolean()) :: boolean()
|
||||
def remove_item(id, character_id, cancel) do
|
||||
case get_item(id) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
item ->
|
||||
if item.seller_id != character_id do
|
||||
false
|
||||
else
|
||||
:ets.delete(@mts_items, id)
|
||||
|
||||
if cancel do
|
||||
# Return item to seller's transfer inventory
|
||||
cart = get_cart(character_id)
|
||||
new_inventory = [item.item | cart.inventory]
|
||||
new_not_yet_sold = List.delete(cart.not_yet_sold, id)
|
||||
|
||||
new_cart = %{
|
||||
cart
|
||||
| inventory: new_inventory,
|
||||
not_yet_sold: new_not_yet_sold
|
||||
}
|
||||
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys an item from the MTS.
|
||||
"""
|
||||
@spec buy_item(integer(), integer(), integer()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
def buy_item(id, buyer_id, offered_price) do
|
||||
case get_item(id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
item ->
|
||||
if item.seller_id == buyer_id do
|
||||
{:error, :own_item}
|
||||
else
|
||||
if offered_price < item.price do
|
||||
{:error, :insufficient_funds}
|
||||
else
|
||||
# Mark as sold and transfer to buyer
|
||||
:ets.delete(@mts_items, id)
|
||||
|
||||
# Add to buyer's transfer inventory
|
||||
buyer_cart = get_cart(buyer_id)
|
||||
new_buyer_inventory = [item.item | buyer_cart.inventory]
|
||||
new_buyer_cart = %{buyer_cart | inventory: new_buyer_inventory}
|
||||
:ets.insert(@mts_carts, {buyer_id, new_buyer_cart})
|
||||
|
||||
# Credit seller with NX
|
||||
seller_cart = get_cart(item.seller_id)
|
||||
new_owed = seller_cart.owed_nx + item.price
|
||||
new_not_yet_sold = List.delete(seller_cart.not_yet_sold, id)
|
||||
|
||||
new_seller_cart = %{
|
||||
seller_cart
|
||||
| owed_nx: new_owed,
|
||||
not_yet_sold: new_not_yet_sold
|
||||
}
|
||||
|
||||
:ets.insert(@mts_carts, {item.seller_id, new_seller_cart})
|
||||
|
||||
{:ok, item}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Searches for items in the MTS.
|
||||
"""
|
||||
@spec search(boolean(), String.t(), integer(), integer()) :: [map()]
|
||||
def search(_cash_search, search_string, type, tab) do
|
||||
# Get all items
|
||||
all_items =
|
||||
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
|
||||
|> Enum.filter(&is_nil(&1.buyer_id))
|
||||
|
||||
# Apply filters
|
||||
items =
|
||||
cond do
|
||||
# Tab 0 = all items
|
||||
tab == 0 ->
|
||||
all_items
|
||||
|
||||
# Tab 1 = search by name
|
||||
tab == 1 && search_string != "" ->
|
||||
Enum.filter(all_items, fn item ->
|
||||
item_name = get_item_name(item.item.item_id)
|
||||
String.contains?(String.downcase(item_name), String.downcase(search_string))
|
||||
end)
|
||||
|
||||
# Type filtering
|
||||
type > 0 ->
|
||||
Enum.filter(all_items, fn item ->
|
||||
get_item_type(item.item.item_id) == type
|
||||
end)
|
||||
|
||||
true ->
|
||||
all_items
|
||||
end
|
||||
|
||||
# Sort by newest first
|
||||
Enum.sort_by(items, & &1.id, :desc)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds an item to the cart.
|
||||
"""
|
||||
@spec add_to_cart(integer(), integer()) :: boolean()
|
||||
def add_to_cart(character_id, item_id) do
|
||||
cart = get_cart(character_id)
|
||||
|
||||
if item_id in cart.cart do
|
||||
false
|
||||
else
|
||||
new_cart = %{cart | cart: [item_id | cart.cart]}
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes an item from the cart.
|
||||
"""
|
||||
@spec remove_from_cart(integer(), integer()) :: boolean()
|
||||
def remove_from_cart(character_id, item_id) do
|
||||
cart = get_cart(character_id)
|
||||
|
||||
if item_id in cart.cart do
|
||||
new_cart = %{cart | cart: List.delete(cart.cart, item_id)}
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Transfers an item from MTS inventory to game inventory.
|
||||
"""
|
||||
@spec transfer_item(integer(), integer()) :: {:ok, map()} | {:error, atom()}
|
||||
def transfer_item(character_id, index) do
|
||||
cart = get_cart(character_id)
|
||||
|
||||
if index < 0 || index >= length(cart.inventory) do
|
||||
{:error, :invalid_index}
|
||||
else
|
||||
item = Enum.at(cart.inventory, index)
|
||||
new_inventory = List.delete_at(cart.inventory, index)
|
||||
new_cart = %{cart | inventory: new_inventory}
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
{:ok, item}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Claims owed NX for a character.
|
||||
"""
|
||||
@spec claim_nx(integer()) :: integer()
|
||||
def claim_nx(character_id) do
|
||||
cart = get_cart(character_id)
|
||||
owed = cart.owed_nx
|
||||
|
||||
if owed > 0 do
|
||||
new_cart = %{cart | owed_nx: 0}
|
||||
:ets.insert(@mts_carts, {character_id, new_cart})
|
||||
end
|
||||
|
||||
owed
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks and removes expired listings.
|
||||
"""
|
||||
@spec check_expirations() :: :ok
|
||||
def check_expirations do
|
||||
now = Odinsea.now()
|
||||
|
||||
expired =
|
||||
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
|
||||
|> Enum.filter(fn item -> item.expiration < now end)
|
||||
|
||||
Enum.each(expired, fn item ->
|
||||
:ets.delete(@mts_items, item.id)
|
||||
|
||||
# Return item to seller
|
||||
cart = get_cart(item.seller_id)
|
||||
new_inventory = [item.item | cart.inventory]
|
||||
new_not_yet_sold = List.delete(cart.not_yet_sold, item.id)
|
||||
|
||||
new_cart = %{
|
||||
cart
|
||||
| inventory: new_inventory,
|
||||
not_yet_sold: new_not_yet_sold
|
||||
}
|
||||
|
||||
:ets.insert(@mts_carts, {item.seller_id, new_cart})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets current MTS listings for display.
|
||||
"""
|
||||
@spec get_current_mts(map()) :: [map()]
|
||||
def get_current_mts(cart) do
|
||||
page_size = 16
|
||||
start_idx = cart.page * page_size
|
||||
|
||||
items =
|
||||
if cart.tab == 0 do
|
||||
:ets.select(@mts_items, [{{:_, :"$1"}, [], [:"$1"]}])
|
||||
else
|
||||
cart.current_view
|
||||
end
|
||||
|
||||
items
|
||||
|> Enum.filter(&is_nil(&1.buyer_id))
|
||||
|> Enum.slice(start_idx, page_size)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets "not yet sold" listings for a character.
|
||||
"""
|
||||
@spec get_not_yet_sold(integer()) :: [map()]
|
||||
def get_not_yet_sold(character_id) do
|
||||
cart = get_cart(character_id)
|
||||
|
||||
Enum.map(cart.not_yet_sold, &get_item/1)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets transfer inventory for a character.
|
||||
"""
|
||||
@spec get_transfer(integer()) :: [map()]
|
||||
def get_transfer(character_id) do
|
||||
cart = get_cart(character_id)
|
||||
cart.inventory
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an item is in the cart.
|
||||
"""
|
||||
@spec in_cart?(integer(), integer()) :: boolean()
|
||||
def in_cart?(character_id, item_id) do
|
||||
cart = get_cart(character_id)
|
||||
item_id in cart.cart
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles MTS operation packets.
|
||||
"""
|
||||
@spec handle(In.t(), map()) :: map()
|
||||
def handle(packet, client_state) do
|
||||
if In.remaining(packet) == 0 do
|
||||
# Empty packet - just refresh
|
||||
send_mts_packets(client_state)
|
||||
else
|
||||
{op, packet} = In.decode_byte(packet)
|
||||
handle_op(op, packet, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
## GenServer Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
:ets.new(@mts_items, [:set, :public, :named_table, read_concurrency: true])
|
||||
:ets.new(@mts_carts, [:set, :public, :named_table])
|
||||
|
||||
# Schedule expiration check
|
||||
schedule_expiration_check()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_expirations, state) do
|
||||
check_expirations()
|
||||
schedule_expiration_check()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
## Private Functions
|
||||
|
||||
defp handle_op(@mts_sell, packet, client_state) do
|
||||
{inv_type, packet} = In.decode_byte(packet)
|
||||
{item_id, packet} = In.decode_int(packet)
|
||||
{has_unique_id, packet} = In.decode_byte(packet)
|
||||
|
||||
if has_unique_id != 0 || (inv_type != 1 && inv_type != 2) do
|
||||
send_mts_fail_sell(client_state)
|
||||
client_state
|
||||
else
|
||||
# Parse item data from packet
|
||||
{item_data, packet} = parse_item_data(packet, inv_type)
|
||||
{price, _packet} = In.decode_int(packet)
|
||||
|
||||
character = client_state.character
|
||||
|
||||
# Validate item can be sold
|
||||
with :ok <- validate_mts_item(character, item_id, item_data, inv_type),
|
||||
:ok <- check_meso_fee(character),
|
||||
true <- length(get_cart(character.id).not_yet_sold) < 10 do
|
||||
# Create item copy
|
||||
item = create_mts_item(character, item_id, item_data)
|
||||
|
||||
# List on MTS
|
||||
{:ok, _listing_id} = list_item(character.id, item, price, character.name)
|
||||
|
||||
# Deduct meso and remove from inventory
|
||||
new_character = deduct_meso(character, @mts_meso)
|
||||
new_character = remove_from_inventory(new_character, inv_type, item_data.slot)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_mts_confirm_sell()
|
||||
else
|
||||
_ ->
|
||||
send_mts_fail_sell(client_state)
|
||||
client_state
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_op(@mts_page, packet, client_state) do
|
||||
{tab, packet} = In.decode_int(packet)
|
||||
{page, packet} = In.decode_int(packet)
|
||||
{type, _packet} = In.decode_int(packet)
|
||||
|
||||
change_cart_info(client_state.character.id, tab, page, type)
|
||||
send_mts_packets(client_state)
|
||||
end
|
||||
|
||||
defp handle_op(@mts_search, packet, client_state) do
|
||||
{tab, packet} = In.decode_int(packet)
|
||||
{page, packet} = In.decode_int(packet)
|
||||
{_zero, packet} = In.decode_int(packet)
|
||||
{cash_search, packet} = In.decode_int(packet)
|
||||
{search_string, _packet} = In.decode_string(packet)
|
||||
|
||||
cart = get_cart(client_state.character.id)
|
||||
change_cart_info(client_state.character.id, tab, page, cart.type)
|
||||
|
||||
# Perform search
|
||||
results = search(cash_search > 0, search_string, cart.type, tab)
|
||||
change_current_view(client_state.character.id, results)
|
||||
|
||||
send_mts_packets(client_state)
|
||||
end
|
||||
|
||||
defp handle_op(@mts_cancel, packet, client_state) do
|
||||
{id, _packet} = In.decode_int(packet)
|
||||
|
||||
if remove_item(id, client_state.character.id, true) do
|
||||
send_mts_confirm_cancel(client_state)
|
||||
send_mts_packets(client_state)
|
||||
else
|
||||
send_mts_fail_cancel(client_state)
|
||||
client_state
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_op(@mts_transfer, packet, client_state) do
|
||||
# Fake ID encoding
|
||||
{fake_id, _packet} = In.decode_int(packet)
|
||||
index = Integer.pow(2, 31) - 1 - fake_id
|
||||
|
||||
case transfer_item(client_state.character.id, index) do
|
||||
{:ok, item} ->
|
||||
# Add to inventory
|
||||
case add_to_inventory(client_state.character, item) do
|
||||
{:ok, new_character, position} ->
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_mts_confirm_transfer(item, position)
|
||||
|> send_mts_packets()
|
||||
|
||||
{:error, _} ->
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_op(@mts_add_cart, packet, client_state) do
|
||||
{id, _packet} = In.decode_int(packet)
|
||||
|
||||
if in_cart?(client_state.character.id, id) do
|
||||
send_cart_message(client_state, true, false)
|
||||
else
|
||||
if add_to_cart(client_state.character.id, id) do
|
||||
send_cart_message(client_state, false, false)
|
||||
else
|
||||
send_cart_message(client_state, true, false)
|
||||
end
|
||||
end
|
||||
|
||||
client_state
|
||||
end
|
||||
|
||||
defp handle_op(@mts_del_cart, packet, client_state) do
|
||||
{id, _packet} = In.decode_int(packet)
|
||||
|
||||
if remove_from_cart(client_state.character.id, id) do
|
||||
send_cart_message(client_state, false, true)
|
||||
else
|
||||
send_cart_message(client_state, true, true)
|
||||
end
|
||||
|
||||
client_state
|
||||
end
|
||||
|
||||
defp handle_op(op, packet, client_state) when op in [@mts_buy_now, @mts_buy_cart] do
|
||||
{id, _packet} = In.decode_int(packet)
|
||||
|
||||
case get_item(id) do
|
||||
nil ->
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
|
||||
item ->
|
||||
if item.seller_id == client_state.character.id do
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
else
|
||||
# Check buyer has enough NX
|
||||
character = client_state.character
|
||||
|
||||
if (character.nx_cash || 0) >= item.price do
|
||||
case buy_item(id, character.id, item.price) do
|
||||
{:ok, _} ->
|
||||
# Deduct NX
|
||||
new_character = %{character | nx_cash: character.nx_cash - item.price}
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_mts_confirm_buy()
|
||||
|> send_mts_packets()
|
||||
|
||||
{:error, _} ->
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
end
|
||||
else
|
||||
send_mts_fail_buy(client_state)
|
||||
client_state
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_op(_op, _packet, client_state) do
|
||||
# Unknown op - just refresh
|
||||
send_mts_packets(client_state)
|
||||
end
|
||||
|
||||
defp parse_item_data(packet, 1) do
|
||||
# Equipment item data
|
||||
packet = In.skip(packet, 32) # Skip various stats
|
||||
{_owner, packet} = In.decode_string(packet)
|
||||
packet = In.skip(packet, 50)
|
||||
{slot, packet} = In.decode_int(packet)
|
||||
packet = In.skip(packet, 4)
|
||||
|
||||
{%{slot: slot}, packet}
|
||||
end
|
||||
|
||||
defp parse_item_data(packet, 2) do
|
||||
# Regular item data
|
||||
{stars, packet} = In.decode_short(packet)
|
||||
{_owner, packet} = In.decode_string(packet)
|
||||
packet = In.skip(packet, 2) # Flag
|
||||
{slot, packet} = In.decode_int(packet)
|
||||
{quantity, _packet} = In.decode_int(packet)
|
||||
|
||||
{%{slot: slot, quantity: quantity, stars: stars}, packet}
|
||||
end
|
||||
|
||||
defp validate_mts_item(character, item_id, item_data, inv_type) do
|
||||
# Check item exists in inventory
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
|
||||
case Enum.find(inventory, &(&1.position == item_data.slot)) do
|
||||
nil ->
|
||||
:error
|
||||
|
||||
item ->
|
||||
if item.item_id == item_id && item.quantity >= (item_data.quantity || 1) do
|
||||
:ok
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp check_meso_fee(character) do
|
||||
if character.meso >= @mts_meso do
|
||||
:ok
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_mts_item(character, item_id, item_data) do
|
||||
%{
|
||||
item_id: item_id,
|
||||
quantity: item_data.quantity || 1,
|
||||
owner: character.name,
|
||||
flag: 0
|
||||
}
|
||||
end
|
||||
|
||||
defp deduct_meso(character, amount) do
|
||||
%{character | meso: character.meso - amount}
|
||||
end
|
||||
|
||||
defp remove_from_inventory(character, inv_type, slot) do
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
new_inventory = Enum.reject(inventory, &(&1.position == slot))
|
||||
inventories = Map.put(character.inventories, inv_type, new_inventory)
|
||||
%{character | inventories: inventories}
|
||||
end
|
||||
|
||||
defp add_to_inventory(character, item) do
|
||||
inv_type = Odinsea.Game.InventoryType.from_item_id(item.item_id)
|
||||
|
||||
if Odinsea.Shop.Operation.check_inventory_space(character, item.item_id, item.quantity) == :ok do
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
position = Inventory.next_free_slot(inventory)
|
||||
new_item = %{item | position: position}
|
||||
new_inventory = [new_item | inventory]
|
||||
inventories = Map.put(character.inventories, inv_type, new_inventory)
|
||||
{:ok, %{character | inventories: inventories}, position}
|
||||
else
|
||||
{:error, :no_space}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_item_name(item_id) do
|
||||
Odinsea.Game.ItemInfo.get_name(item_id) || "Unknown"
|
||||
end
|
||||
|
||||
defp get_item_type(item_id) do
|
||||
cond do
|
||||
item_id >= 1_000_000 && item_id < 2_000_000 -> 1
|
||||
item_id >= 2_000_000 && item_id < 3_000_000 -> 2
|
||||
item_id >= 4_000_000 && item_id < 5_000_000 -> 4
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_listing_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp schedule_expiration_check do
|
||||
# Check every hour
|
||||
Process.send_after(self(), :check_expirations, 60 * 60 * 1000)
|
||||
end
|
||||
|
||||
# Packet senders
|
||||
defp send_mts_packets(client_state) do
|
||||
cart = get_cart(client_state.character.id)
|
||||
|
||||
Odinsea.Shop.Packets.send_current_mts(client_state.socket, cart)
|
||||
Odinsea.Shop.Packets.send_not_yet_sold(client_state.socket, cart)
|
||||
Odinsea.Shop.Packets.send_transfer(client_state.socket, cart)
|
||||
Odinsea.Shop.Packets.show_mts_cash(client_state.socket, client_state.character)
|
||||
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
|
||||
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_mts_fail_sell(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_fail_sell(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_confirm_sell(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_confirm_sell(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_fail_cancel(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_fail_cancel(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_confirm_cancel(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_confirm_cancel(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_fail_buy(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_fail_buy(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_confirm_buy(client_state) do
|
||||
Odinsea.Shop.Packets.get_mts_confirm_buy(client_state.socket)
|
||||
end
|
||||
|
||||
defp send_mts_confirm_transfer(client_state, _item, position) do
|
||||
# This needs the inventory type encoded
|
||||
Odinsea.Shop.Packets.get_mts_confirm_transfer(client_state.socket, 1, position)
|
||||
end
|
||||
|
||||
defp send_cart_message(client_state, failed, deleted) do
|
||||
Odinsea.Shop.Packets.add_to_cart_message(client_state.socket, failed, deleted)
|
||||
end
|
||||
end
|
||||
923
lib/odinsea/shop/operation.ex
Normal file
923
lib/odinsea/shop/operation.ex
Normal file
@@ -0,0 +1,923 @@
|
||||
defmodule Odinsea.Shop.Operation do
|
||||
@moduledoc """
|
||||
Cash Shop Operation handlers.
|
||||
|
||||
Implements all cash shop functionality:
|
||||
- Buying items with NX/Maple Points
|
||||
- Gifting items to other players
|
||||
- Wish list management
|
||||
- Coupon redemption
|
||||
- Inventory slot expansion
|
||||
- Storage slot expansion
|
||||
- Character slot expansion
|
||||
|
||||
Ported from handling/cashshop/handler/CashShopOperation.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Shop.{CashItem, CashItemFactory, Packets}
|
||||
alias Odinsea.Game.{Inventory, InventoryType, ItemInfo}
|
||||
alias Odinsea.Database.Context
|
||||
alias Odinsea.Net.Packet.In
|
||||
|
||||
# Cash shop action codes from Java
|
||||
@action_coupon 0
|
||||
@action_buy 3
|
||||
@action_gift 4
|
||||
@action_wishlist 5
|
||||
@action_expand_inv 6
|
||||
@action_expand_storage 7
|
||||
@action_expand_chars 8
|
||||
@action_to_inv 14
|
||||
@action_to_cash_inv 15
|
||||
@action_buy_friendship_ring 30
|
||||
@action_buy_package 32
|
||||
@action_buy_quest 34
|
||||
@action_redeem 45
|
||||
|
||||
# Error codes
|
||||
@error_none 0
|
||||
@error_no_coupon 0xA5
|
||||
@error_used_coupon 0xA7
|
||||
@error_invalid_gender 0xA6
|
||||
@error_no_space 0xB1
|
||||
@error_invalid_target 0xA2
|
||||
@error_same_account 0xA3
|
||||
@error_invalid_slot 0xA4
|
||||
@error_not_enough_meso 0xB8
|
||||
@error_invalid_couple 0xA1
|
||||
@error_invalid_ring 0xB4
|
||||
|
||||
@doc """
|
||||
Handles a cash shop operation packet.
|
||||
"""
|
||||
@spec handle(In.t(), map()) :: map()
|
||||
def handle(packet, client_state) do
|
||||
{action, packet} = In.decode_byte(packet)
|
||||
handle_action(action, packet, client_state)
|
||||
end
|
||||
|
||||
# Coupon code redemption
|
||||
defp handle_action(@action_coupon, packet, client_state) do
|
||||
packet = In.skip(packet, 2)
|
||||
{code, _packet} = In.decode_string(packet)
|
||||
|
||||
redeem_coupon(code, client_state)
|
||||
end
|
||||
|
||||
# Buy item
|
||||
defp handle_action(@action_buy, packet, client_state) do
|
||||
packet = In.skip(packet, 1)
|
||||
# toCharge = 1 for NX, 2 for Maple Points
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{sn, _packet} = In.decode_int(packet)
|
||||
|
||||
buy_item(sn, to_charge, client_state)
|
||||
end
|
||||
|
||||
# Gift item
|
||||
defp handle_action(@action_gift, packet, client_state) do
|
||||
# Skip separator string
|
||||
{_sep, packet} = In.decode_string(packet)
|
||||
{sn, packet} = In.decode_int(packet)
|
||||
{partner_name, packet} = In.decode_string(packet)
|
||||
{msg, _packet} = In.decode_string(packet)
|
||||
|
||||
gift_item(sn, partner_name, msg, client_state)
|
||||
end
|
||||
|
||||
# Wish list
|
||||
defp handle_action(@action_wishlist, packet, client_state) do
|
||||
# Read 10 wishlist items
|
||||
wishlist =
|
||||
Enum.reduce(1..10, {[], packet}, fn _, {list, pkt} ->
|
||||
{sn, new_pkt} = In.decode_int(pkt)
|
||||
{[sn | list], new_pkt}
|
||||
end)
|
||||
|> elem(0)
|
||||
|> Enum.reverse()
|
||||
|
||||
update_wishlist(wishlist, client_state)
|
||||
end
|
||||
|
||||
# Expand inventory
|
||||
defp handle_action(@action_expand_inv, packet, client_state) do
|
||||
packet = In.skip(packet, 1)
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{use_coupon, packet} = In.decode_byte(packet)
|
||||
|
||||
if use_coupon > 0 do
|
||||
{sn, _packet} = In.decode_int(packet)
|
||||
expand_inventory_coupon(sn, to_charge, client_state)
|
||||
else
|
||||
{inv_type, _packet} = In.decode_byte(packet)
|
||||
expand_inventory(inv_type, to_charge, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
# Expand storage
|
||||
defp handle_action(@action_expand_storage, packet, client_state) do
|
||||
packet = In.skip(packet, 1)
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{coupon, _packet} = In.decode_byte(packet)
|
||||
|
||||
slots = if coupon > 0, do: 8, else: 4
|
||||
cost = if coupon > 0, do: 8_000, else: 4_000
|
||||
|
||||
expand_storage(slots, cost * div(slots, 4), to_charge, client_state)
|
||||
end
|
||||
|
||||
# Expand character slots
|
||||
defp handle_action(@action_expand_chars, packet, client_state) do
|
||||
packet = In.skip(packet, 1)
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{sn, _packet} = In.decode_int(packet)
|
||||
|
||||
expand_character_slots(sn, to_charge, client_state)
|
||||
end
|
||||
|
||||
# Move item from cash inventory to regular inventory
|
||||
defp handle_action(@action_to_inv, packet, client_state) do
|
||||
{cash_id, _packet} = In.decode_long(packet)
|
||||
move_to_inventory(cash_id, client_state)
|
||||
end
|
||||
|
||||
# Move item from regular inventory to cash inventory
|
||||
defp handle_action(@action_to_cash_inv, packet, client_state) do
|
||||
{unique_id, packet} = In.decode_long(packet)
|
||||
{inv_type, _packet} = In.decode_byte(packet)
|
||||
|
||||
move_to_cash_inventory(unique_id, inv_type, client_state)
|
||||
end
|
||||
|
||||
# Buy friendship/crush ring
|
||||
defp handle_action(@action_buy_friendship_ring, packet, client_state) do
|
||||
{_sep, packet} = In.decode_string(packet)
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{sn, packet} = In.decode_int(packet)
|
||||
{partner_name, packet} = In.decode_string(packet)
|
||||
{msg, _packet} = In.decode_string(packet)
|
||||
|
||||
buy_ring(sn, partner_name, msg, to_charge, client_state)
|
||||
end
|
||||
|
||||
# Buy package
|
||||
defp handle_action(@action_buy_package, packet, client_state) do
|
||||
packet = In.skip(packet, 1)
|
||||
{to_charge, packet} = In.decode_int(packet)
|
||||
{sn, _packet} = In.decode_int(packet)
|
||||
|
||||
buy_package(sn, to_charge, client_state)
|
||||
end
|
||||
|
||||
# Buy quest item (with meso)
|
||||
defp handle_action(@action_buy_quest, packet, client_state) do
|
||||
{sn, _packet} = In.decode_int(packet)
|
||||
buy_quest_item(sn, client_state)
|
||||
end
|
||||
|
||||
# Redeem code
|
||||
defp handle_action(@action_redeem, _packet, client_state) do
|
||||
send_redeem_response(client_state)
|
||||
end
|
||||
|
||||
# Unknown action
|
||||
defp handle_action(action, _packet, client_state) do
|
||||
Logger.warning("Unknown cash shop action: #{action}")
|
||||
send_error(@error_none, client_state)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# CS Update (Initial Setup)
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Sends initial cash shop update packets.
|
||||
Called when player enters the cash shop.
|
||||
"""
|
||||
@spec cs_update(port(), map()) :: :ok
|
||||
def cs_update(socket, character) do
|
||||
Packets.get_cs_inventory(socket, character)
|
||||
Packets.show_nx_maple_tokens(socket, character)
|
||||
Packets.enable_cs_use(socket)
|
||||
:ok
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Implementation Functions
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Buys a cash item for the player.
|
||||
"""
|
||||
@spec buy_item(integer(), integer(), map()) :: map()
|
||||
def buy_item(sn, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
:ok <- check_gender(item, character),
|
||||
:ok <- check_cash_inventory_space(character),
|
||||
:ok <- check_blocked_item(item),
|
||||
:ok <- check_cash_balance(character, to_charge, item.price) do
|
||||
# Deduct NX/Maple Points
|
||||
new_character = modify_cs_points(character, to_charge, -item.price)
|
||||
|
||||
# Create item in cash inventory
|
||||
cash_item = create_cash_item(item, "")
|
||||
new_character = add_to_cash_inventory(new_character, cash_item)
|
||||
|
||||
# Send success packet
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_bought_item(cash_item, sn)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gifts an item to another player.
|
||||
"""
|
||||
@spec gift_item(integer(), String.t(), String.t(), map()) :: map()
|
||||
def gift_item(sn, partner_name, msg, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
:ok <- validate_gift_message(msg),
|
||||
:ok <- check_cash_balance(character, 1, item.price),
|
||||
{:ok, target} <- find_character_by_name(partner_name),
|
||||
:ok <- validate_gift_target(character, target),
|
||||
:ok <- check_gender(item, target) do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, 1, -item.price)
|
||||
|
||||
# Create gift record
|
||||
create_gift(target.id, character.name, msg, item)
|
||||
|
||||
# Send success packet
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_gift_sent(item, partner_name)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Redeems a coupon code.
|
||||
"""
|
||||
@spec redeem_coupon(String.t(), map()) :: map()
|
||||
def redeem_coupon(code, client_state) do
|
||||
if code == "" do
|
||||
send_error(@error_none, client_state)
|
||||
else
|
||||
# Check coupon in database
|
||||
case Context.get_coupon_info(code) do
|
||||
{:ok, %{used: false, type: type, value: value}} ->
|
||||
# Mark coupon as used
|
||||
Context.mark_coupon_used(code, client_state.character.name)
|
||||
|
||||
# Apply coupon reward
|
||||
apply_coupon_reward(type, value, client_state)
|
||||
|
||||
{:ok, %{used: true}} ->
|
||||
send_error(@error_used_coupon, client_state)
|
||||
|
||||
_ ->
|
||||
send_error(@error_no_coupon, client_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the player's wishlist.
|
||||
"""
|
||||
@spec update_wishlist([integer()], map()) :: map()
|
||||
def update_wishlist(wishlist, client_state) do
|
||||
# Validate all items exist
|
||||
valid_items =
|
||||
Enum.filter(wishlist, fn sn ->
|
||||
CashItemFactory.get_item(sn) != nil
|
||||
end)
|
||||
|> Enum.take(10)
|
||||
|> pad_wishlist()
|
||||
|
||||
# Update character wishlist
|
||||
new_character = %{client_state.character | wishlist: valid_items}
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_wishlist(valid_items)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Expands inventory slots.
|
||||
"""
|
||||
@spec expand_inventory(integer(), integer(), map()) :: map()
|
||||
def expand_inventory(inv_type, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
cost = 4_000
|
||||
|
||||
with :ok <- check_cash_balance(character, to_charge, cost),
|
||||
{:ok, inventory_type} <- get_inventory_type(inv_type),
|
||||
:ok <- check_slot_limit(character, inventory_type) do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -cost)
|
||||
|
||||
# Add slots (max 96)
|
||||
slots_to_add = min(96 - get_current_slots(new_character, inventory_type), 4)
|
||||
new_character = add_inventory_slots(new_character, inventory_type, slots_to_add)
|
||||
|
||||
send_inventory_expanded(client_state, inventory_type, slots_to_add)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Expands inventory using a coupon item.
|
||||
"""
|
||||
@spec expand_inventory_coupon(integer(), integer(), map()) :: map()
|
||||
def expand_inventory_coupon(sn, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
:ok <- check_cash_balance(character, to_charge, item.price),
|
||||
{:ok, inventory_type} <- get_inventory_type_from_item(item),
|
||||
:ok <- check_slot_limit(character, inventory_type) do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -item.price)
|
||||
|
||||
# Add slots
|
||||
slots_to_add = min(96 - get_current_slots(new_character, inventory_type), 8)
|
||||
new_character = add_inventory_slots(new_character, inventory_type, slots_to_add)
|
||||
|
||||
send_inventory_expanded(client_state, inventory_type, slots_to_add)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Expands storage slots.
|
||||
"""
|
||||
@spec expand_storage(integer(), integer(), integer(), map()) :: map()
|
||||
def expand_storage(slots, cost, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
current_slots = character.storage_slots || 4
|
||||
max_slots = 49 - slots
|
||||
|
||||
if current_slots >= max_slots do
|
||||
send_error(@error_invalid_slot, client_state)
|
||||
else
|
||||
with :ok <- check_cash_balance(character, to_charge, cost) do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -cost)
|
||||
|
||||
# Add slots
|
||||
new_slots = min(current_slots + slots, max_slots)
|
||||
new_character = %{new_character | storage_slots: new_slots}
|
||||
|
||||
send_storage_expanded(client_state, new_slots)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Expands character slots.
|
||||
"""
|
||||
@spec expand_character_slots(integer(), integer(), map()) :: map()
|
||||
def expand_character_slots(sn, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
:ok <- check_cash_balance(character, to_charge, item.price),
|
||||
true <- item.item_id == 5_430_000,
|
||||
current_slots <- client_state.account.character_slots || 3,
|
||||
true <- current_slots < 15 do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -item.price)
|
||||
|
||||
# Add slot
|
||||
Context.increment_character_slots(client_state.account.id)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_character_slots_expanded(current_slots + 1)
|
||||
else
|
||||
_ -> send_error(@error_none, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Moves item from cash inventory to regular inventory.
|
||||
"""
|
||||
@spec move_to_inventory(integer(), map()) :: map()
|
||||
def move_to_inventory(cash_id, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- find_cash_item(character, cash_id),
|
||||
:ok <- check_inventory_space(character, item.item_id, item.quantity),
|
||||
{:ok, new_character, position} <- add_to_inventory(character, item) do
|
||||
# Remove from cash inventory
|
||||
new_character = remove_from_cash_inventory(new_character, cash_id)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_moved_to_inventory(item, position)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Moves item from regular inventory to cash inventory.
|
||||
"""
|
||||
@spec move_to_cash_inventory(integer(), integer(), map()) :: map()
|
||||
def move_to_cash_inventory(unique_id, inv_type, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- find_inventory_item(character, inv_type, unique_id),
|
||||
:ok <- check_cash_inventory_space(character) do
|
||||
# Remove from inventory
|
||||
new_character = remove_from_inventory(character, inv_type, unique_id)
|
||||
|
||||
# Add to cash inventory
|
||||
cash_item = %{item | position: 0}
|
||||
new_character = add_to_cash_inventory(new_character, cash_item)
|
||||
|
||||
send_moved_to_cash_inventory(client_state, cash_item)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys a friendship/crush ring.
|
||||
"""
|
||||
@spec buy_ring(integer(), String.t(), String.t(), integer(), map()) :: map()
|
||||
def buy_ring(sn, partner_name, msg, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
:ok <- validate_gift_message(msg),
|
||||
:ok <- check_gender(item, character),
|
||||
:ok <- check_cash_inventory_space(character),
|
||||
:ok <- check_cash_balance(character, to_charge, item.price),
|
||||
{:ok, target} <- find_character_by_name(partner_name),
|
||||
:ok <- validate_ring_target(character, target) do
|
||||
# Create ring (simplified - would need proper ring creation)
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -item.price)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_ring_purchased(item, partner_name)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys a package (contains multiple items).
|
||||
"""
|
||||
@spec buy_package(integer(), integer(), map()) :: map()
|
||||
def buy_package(sn, to_charge, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
{:ok, package_items} <- get_package_items(item.item_id),
|
||||
:ok <- check_gender(item, character),
|
||||
:ok <- check_cash_inventory_space_for_package(character, length(package_items)),
|
||||
:ok <- check_cash_balance(character, to_charge, item.price) do
|
||||
# Deduct NX
|
||||
new_character = modify_cs_points(character, to_charge, -item.price)
|
||||
|
||||
# Add all package items to inventory
|
||||
{new_character, items_added} =
|
||||
Enum.reduce(package_items, {new_character, []}, fn pkg_sn, {char, list} ->
|
||||
case CashItemFactory.get_simple_item(pkg_sn) do
|
||||
nil ->
|
||||
{char, list}
|
||||
|
||||
pkg_item ->
|
||||
cash_item = create_cash_item(pkg_item, "")
|
||||
char = add_to_cash_inventory(char, cash_item)
|
||||
{char, [cash_item | list]}
|
||||
end
|
||||
end)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_package_purchased(items_added)
|
||||
else
|
||||
{:error, code} -> send_error(code, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Buys a quest item with meso.
|
||||
"""
|
||||
@spec buy_quest_item(integer(), map()) :: map()
|
||||
def buy_quest_item(sn, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
with {:ok, item} <- validate_cash_item(sn),
|
||||
true <- ItemInfo.is_quest?(item.item_id),
|
||||
:ok <- check_meso_balance(character, item.price),
|
||||
:ok <- check_inventory_space(character, item.item_id, item.count) do
|
||||
# Deduct meso
|
||||
new_character = %{character | meso: character.meso - item.price}
|
||||
|
||||
# Add item
|
||||
{:ok, new_character, position} = add_item_to_inventory(new_character, item)
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_quest_item_purchased(item, position)
|
||||
else
|
||||
_ -> send_error(@error_none, client_state)
|
||||
end
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Helper Functions
|
||||
# ==============================================================================
|
||||
|
||||
defp validate_cash_item(sn) do
|
||||
case CashItemFactory.get_item(sn) do
|
||||
nil -> {:error, @error_none}
|
||||
item -> {:ok, item}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_gender(item, character) do
|
||||
if CashItem.gender_matches?(item, character.gender) do
|
||||
:ok
|
||||
else
|
||||
{:error, @error_invalid_gender}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_cash_inventory_space(character) do
|
||||
cash_items = character.cash_inventory || []
|
||||
|
||||
if length(cash_items) >= 100 do
|
||||
{:error, @error_no_space}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_cash_inventory_space_for_package(character, count) do
|
||||
cash_items = character.cash_inventory || []
|
||||
|
||||
if length(cash_items) + count > 100 do
|
||||
{:error, @error_no_space}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_blocked_item(item) do
|
||||
if CashItemFactory.blocked?(item.item_id) do
|
||||
{:error, @error_none}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_cash_balance(character, type, amount) do
|
||||
balance = if type == 1, do: character.nx_cash || 0, else: character.maple_points || 0
|
||||
|
||||
if balance >= amount do
|
||||
:ok
|
||||
else
|
||||
{:error, @error_none}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_meso_balance(character, amount) do
|
||||
if character.meso >= amount do
|
||||
:ok
|
||||
else
|
||||
{:error, @error_not_enough_meso}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if there's space in inventory for an item.
|
||||
"""
|
||||
@spec check_inventory_space(map(), integer(), integer()) :: :ok | {:error, integer()}
|
||||
def check_inventory_space(character, item_id, quantity) do
|
||||
inv_type = InventoryType.from_item_id(item_id)
|
||||
|
||||
if Inventory.has_space?(character.inventories[inv_type], item_id, quantity) do
|
||||
:ok
|
||||
else
|
||||
{:error, @error_no_space}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_slot_limit(character, inventory_type) do
|
||||
slots = get_current_slots(character, inventory_type)
|
||||
|
||||
if slots >= 96 do
|
||||
{:error, @error_invalid_slot}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_gift_message(msg) do
|
||||
if String.length(msg) > 73 || msg == "" do
|
||||
{:error, @error_none}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp find_character_by_name(name) do
|
||||
case Context.get_character_by_name(name) do
|
||||
nil -> {:error, @error_invalid_target}
|
||||
character -> {:ok, character}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_gift_target(character, target) do
|
||||
cond do
|
||||
target.id == character.id -> {:error, @error_invalid_target}
|
||||
target.account_id == character.account_id -> {:error, @error_same_account}
|
||||
true -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_ring_target(character, target) do
|
||||
cond do
|
||||
target.id == character.id -> {:error, @error_invalid_ring}
|
||||
target.account_id == character.account_id -> {:error, @error_same_account}
|
||||
true -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp get_package_items(item_id) do
|
||||
case CashItemFactory.get_package_items(item_id) do
|
||||
nil -> {:error, @error_none}
|
||||
items -> {:ok, items}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_gift(recipient_id, from, msg, item) do
|
||||
Context.create_gift(%{
|
||||
recipient_id: recipient_id,
|
||||
from: from,
|
||||
message: msg,
|
||||
sn: item.sn,
|
||||
unique_id: generate_unique_id()
|
||||
})
|
||||
end
|
||||
|
||||
defp create_cash_item(cash_item_info, gift_from) do
|
||||
%{
|
||||
unique_id: generate_unique_id(),
|
||||
item_id: cash_item_info.item_id,
|
||||
quantity: cash_item_info.count,
|
||||
expiration: CashItem.expiration_time(cash_item_info),
|
||||
gift_from: gift_from,
|
||||
sn: cash_item_info.sn
|
||||
}
|
||||
end
|
||||
|
||||
defp modify_cs_points(character, type, amount) do
|
||||
if type == 1 do
|
||||
%{character | nx_cash: (character.nx_cash || 0) + amount}
|
||||
else
|
||||
%{character | maple_points: (character.maple_points || 0) + amount}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_to_cash_inventory(character, item) do
|
||||
cash_inv = character.cash_inventory || []
|
||||
%{character | cash_inventory: [item | cash_inv]}
|
||||
end
|
||||
|
||||
defp remove_from_cash_inventory(character, cash_id) do
|
||||
cash_inv =
|
||||
Enum.reject(character.cash_inventory || [], fn item ->
|
||||
item.unique_id == cash_id
|
||||
end)
|
||||
|
||||
%{character | cash_inventory: cash_inv}
|
||||
end
|
||||
|
||||
defp find_cash_item(character, cash_id) do
|
||||
case Enum.find(character.cash_inventory || [], &(&1.unique_id == cash_id)) do
|
||||
nil -> {:error, @error_none}
|
||||
item -> {:ok, item}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_inventory_type(inv_type) do
|
||||
case inv_type do
|
||||
1 -> {:ok, :equip}
|
||||
2 -> {:ok, :use}
|
||||
3 -> {:ok, :setup}
|
||||
4 -> {:ok, :etc}
|
||||
_ -> {:error, @error_invalid_slot}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_inventory_type_from_item(item) do
|
||||
type = div(item.item_id, 1000)
|
||||
|
||||
case type do
|
||||
9111 -> {:ok, :equip}
|
||||
9112 -> {:ok, :use}
|
||||
9113 -> {:ok, :setup}
|
||||
9114 -> {:ok, :etc}
|
||||
_ -> {:error, @error_invalid_slot}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_current_slots(character, inventory_type) do
|
||||
case character.inventory_limits[inventory_type] do
|
||||
nil -> 24
|
||||
limit -> limit
|
||||
end
|
||||
end
|
||||
|
||||
defp add_inventory_slots(character, inventory_type, slots) do
|
||||
current = get_current_slots(character, inventory_type)
|
||||
new_limits = Map.put(character.inventory_limits || %{}, inventory_type, current + slots)
|
||||
%{character | inventory_limits: new_limits}
|
||||
end
|
||||
|
||||
defp find_inventory_item(character, inv_type, unique_id) do
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
|
||||
case Enum.find(inventory, &(&1.unique_id == unique_id)) do
|
||||
nil -> {:error, @error_none}
|
||||
item -> {:ok, item}
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_from_inventory(character, inv_type, unique_id) do
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
new_inventory = Enum.reject(inventory, &(&1.unique_id == unique_id))
|
||||
inventories = Map.put(character.inventories, inv_type, new_inventory)
|
||||
%{character | inventories: inventories}
|
||||
end
|
||||
|
||||
defp add_to_inventory(character, item) do
|
||||
inv_type = InventoryType.from_item_id(item.item_id)
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
position = Inventory.next_free_slot(inventory)
|
||||
new_item = %{item | position: position}
|
||||
new_inventory = [new_item | inventory]
|
||||
inventories = Map.put(character.inventories, inv_type, new_inventory)
|
||||
{:ok, %{character | inventories: inventories}, position}
|
||||
end
|
||||
|
||||
defp add_item_to_inventory(character, item) do
|
||||
inv_type = InventoryType.from_item_id(item.item_id)
|
||||
inventory = Map.get(character.inventories, inv_type, [])
|
||||
position = Inventory.next_free_slot(inventory)
|
||||
|
||||
new_item = %{
|
||||
unique_id: generate_unique_id(),
|
||||
item_id: item.item_id,
|
||||
position: position,
|
||||
quantity: item.count
|
||||
}
|
||||
|
||||
new_inventory = [new_item | inventory]
|
||||
inventories = Map.put(character.inventories, inv_type, new_inventory)
|
||||
{:ok, %{character | inventories: inventories}, position}
|
||||
end
|
||||
|
||||
defp apply_coupon_reward(type, value, client_state) do
|
||||
character = client_state.character
|
||||
|
||||
{new_character, items, maple_points, mesos} =
|
||||
case type do
|
||||
1 ->
|
||||
# NX Cash
|
||||
{modify_cs_points(character, 1, value), %{}, value, 0}
|
||||
|
||||
2 ->
|
||||
# Maple Points
|
||||
{modify_cs_points(character, 2, value), %{}, value, 0}
|
||||
|
||||
3 ->
|
||||
# Item
|
||||
case CashItemFactory.get_item(value) do
|
||||
nil ->
|
||||
{character, %{}, 0, 0}
|
||||
|
||||
item ->
|
||||
cash_item = create_cash_item(item, "")
|
||||
new_char = add_to_cash_inventory(character, cash_item)
|
||||
{new_char, %{value => cash_item}, 0, 0}
|
||||
end
|
||||
|
||||
4 ->
|
||||
# Mesos
|
||||
{%{character | meso: character.meso + value}, %{}, 0, value}
|
||||
|
||||
_ ->
|
||||
{character, %{}, 0, 0}
|
||||
end
|
||||
|
||||
client_state
|
||||
|> Map.put(:character, new_character)
|
||||
|> send_coupon_redeemed(items, maple_points, mesos)
|
||||
end
|
||||
|
||||
defp pad_wishlist(list) do
|
||||
padding = List.duplicate(0, 10 - length(list))
|
||||
list ++ padding
|
||||
end
|
||||
|
||||
defp generate_unique_id do
|
||||
:erlang.unique_integer([:positive])
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Packet Senders (delegated to Packets module)
|
||||
# ==============================================================================
|
||||
|
||||
defp send_error(code, client_state) do
|
||||
Odinsea.Shop.Packets.send_cs_fail(client_state.socket, code)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_bought_item(client_state, item, sn) do
|
||||
Odinsea.Shop.Packets.show_bought_cs_item(client_state.socket, item, sn, client_state.account_id)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_gift_sent(client_state, item, partner) do
|
||||
Odinsea.Shop.Packets.send_gift(client_state.socket, item.price, item.item_id, item.count, partner)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_wishlist(client_state, wishlist) do
|
||||
Odinsea.Shop.Packets.send_wishlist(client_state.socket, client_state.character, wishlist)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_inventory_expanded(client_state, inv_type, slots) do
|
||||
# Send appropriate packet
|
||||
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_storage_expanded(client_state, slots) do
|
||||
# Send appropriate packet
|
||||
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_character_slots_expanded(client_state, slots) do
|
||||
Odinsea.Shop.Packets.enable_cs_use(client_state.socket)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_moved_to_inventory(client_state, item, position) do
|
||||
Odinsea.Shop.Packets.confirm_from_cs_inventory(client_state.socket, item, position)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_moved_to_cash_inventory(client_state, item) do
|
||||
Odinsea.Shop.Packets.confirm_to_cs_inventory(client_state.socket, item, client_state.account_id)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_ring_purchased(client_state, item, partner) do
|
||||
Odinsea.Shop.Packets.send_gift(client_state.socket, item.price, item.item_id, item.count, partner)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_package_purchased(client_state, items) do
|
||||
Odinsea.Shop.Packets.show_bought_cs_package(client_state.socket, items, client_state.account_id)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_quest_item_purchased(client_state, item, position) do
|
||||
Odinsea.Shop.Packets.show_bought_cs_quest_item(client_state.socket, item, position)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_coupon_redeemed(client_state, items, maple_points, mesos) do
|
||||
Odinsea.Shop.Packets.show_coupon_redeemed(client_state.socket, items, maple_points, mesos, client_state)
|
||||
client_state
|
||||
end
|
||||
|
||||
defp send_redeem_response(client_state) do
|
||||
Odinsea.Shop.Packets.redeem_response(client_state.socket)
|
||||
client_state
|
||||
end
|
||||
end
|
||||
711
lib/odinsea/shop/packets.ex
Normal file
711
lib/odinsea/shop/packets.ex
Normal file
@@ -0,0 +1,711 @@
|
||||
defmodule Odinsea.Shop.Packets do
|
||||
@moduledoc """
|
||||
Cash Shop and MTS packet builders.
|
||||
|
||||
Ported from Java tools.packet.MTSCSPacket
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Shop.CashItem
|
||||
|
||||
# Cash shop operation codes for responses
|
||||
@cs_success 0x00
|
||||
@cs_fail 0x01
|
||||
|
||||
# ==============================================================================
|
||||
# Cash Shop Entry/Setup Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Sets up the cash shop for a player.
|
||||
Sent when player enters the cash shop.
|
||||
"""
|
||||
def set_cash_shop(character) do
|
||||
Out.new(Opcodes.lp_set_cash_shop())
|
||||
|> encode_cash_shop_info(character)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes the full cash shop info structure.
|
||||
"""
|
||||
defp encode_cash_shop_info(packet, character) do
|
||||
# Best items (featured items)
|
||||
best_items = Odinsea.Shop.CashItemFactory.get_best_items()
|
||||
|
||||
packet
|
||||
# encodeStock
|
||||
|> encode_stock()
|
||||
# encodeCategory
|
||||
|> encode_categories()
|
||||
# encodeBest
|
||||
|> encode_best_items(best_items)
|
||||
# encodeGateway
|
||||
|> encode_gateway()
|
||||
# encodeLimitGoods
|
||||
|> encode_limit_goods()
|
||||
# encodeZeroGoods
|
||||
|> encode_zero_goods()
|
||||
# encodeCategoryInfo
|
||||
|> encode_category_info()
|
||||
# Character info
|
||||
|> encode_character_cash_info(character)
|
||||
end
|
||||
|
||||
defp encode_stock(packet) do
|
||||
# Stock counts - simplified, no limited stock items
|
||||
Out.encode_short(packet, 0)
|
||||
end
|
||||
|
||||
defp encode_categories(packet) do
|
||||
categories = Odinsea.Shop.CashItemFactory.get_categories()
|
||||
|
||||
packet
|
||||
|> Out.encode_short(length(categories))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(categories, pkt, fn cat, p ->
|
||||
p
|
||||
|> Out.encode_byte(cat.category)
|
||||
|> Out.encode_byte(cat.sub_category)
|
||||
|> Out.encode_byte(cat.discount_rate)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_best_items(packet, items) do
|
||||
# Best items for each category/gender combination
|
||||
packet
|
||||
|> Out.encode_short(5) # Category count
|
||||
|> Out.encode_short(2) # Gender count (male/female)
|
||||
|> Out.encode_short(1) # Items per cell
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(0..4, pkt, fn _i, p1 ->
|
||||
Enum.reduce(0..1, p1, fn _j, p2 ->
|
||||
# Featured item for this category/gender
|
||||
item_sn = Enum.random(items) || 0
|
||||
p2
|
||||
|> Out.encode_int(item_sn)
|
||||
|> Out.encode_short(10000) # Category SN
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_gateway(packet) do
|
||||
# Gateway info - empty for now
|
||||
Out.encode_byte(packet, 0)
|
||||
end
|
||||
|
||||
defp encode_limit_goods(packet) do
|
||||
# Limited goods - empty
|
||||
Out.encode_short(packet, 0)
|
||||
end
|
||||
|
||||
defp encode_zero_goods(packet) do
|
||||
# Zero goods - empty
|
||||
Out.encode_short(packet, 0)
|
||||
end
|
||||
|
||||
defp encode_category_info(packet) do
|
||||
# Category parent info
|
||||
Out.encode_byte(packet, 0)
|
||||
end
|
||||
|
||||
defp encode_character_cash_info(packet, character) do
|
||||
packet
|
||||
|> Out.encode_int(character.nx_cash || 0)
|
||||
|> Out.encode_int(character.maple_points || 0)
|
||||
|> Out.encode_int(character.id)
|
||||
# Gift token - not implemented
|
||||
|> Out.encode_int(0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables cash shop usage.
|
||||
"""
|
||||
def enable_cs_use(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> encode_cs_update()
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_cs_update(packet) do
|
||||
# Flag indicating update type
|
||||
Out.encode_byte(packet, @cs_success)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the player's cash inventory.
|
||||
"""
|
||||
def get_cs_inventory(socket, character) do
|
||||
cash_items = character.cash_inventory || []
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x4A) # Operation code for inventory
|
||||
|> encode_cash_items(cash_items)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_cash_items(packet, items) do
|
||||
packet
|
||||
|> Out.encode_short(length(items))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn item, p ->
|
||||
encode_cash_item(p, item)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_cash_item(packet, item) do
|
||||
packet
|
||||
|> Out.encode_long(item.unique_id)
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_int(item.sn || 0)
|
||||
|> Out.encode_short(item.quantity)
|
||||
|> Out.encode_string(item.gift_from || "")
|
||||
|> Out.encode_long(item.expiration || -1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows NX and Maple Point balances.
|
||||
"""
|
||||
def show_nx_maple_tokens(socket, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x3D) # Operation code for balance
|
||||
|> Out.encode_int(character.nx_cash || 0)
|
||||
|> Out.encode_int(character.maple_points || 0)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Purchase Response Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Shows a successfully purchased cash item.
|
||||
"""
|
||||
def show_bought_cs_item(socket, item, sn, account_id) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x53) # Bought item operation
|
||||
|> Out.encode_int(account_id)
|
||||
|> encode_bought_item(item, sn)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_bought_item(packet, item, sn) do
|
||||
packet
|
||||
|> Out.encode_long(item.unique_id)
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_int(sn)
|
||||
|> Out.encode_short(item.quantity)
|
||||
|> Out.encode_string(item.gift_from || "")
|
||||
|> Out.encode_long(item.expiration || -1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows a successfully purchased package.
|
||||
"""
|
||||
def show_bought_cs_package(socket, items, account_id) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x5D) # Package operation
|
||||
|> Out.encode_int(account_id)
|
||||
|> Out.encode_short(length(items))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn item, p ->
|
||||
encode_bought_item(p, item, item.sn)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows a successfully purchased quest item.
|
||||
"""
|
||||
def show_bought_cs_quest_item(socket, item, position) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x73) # Quest item operation
|
||||
|> Out.encode_int(item.price)
|
||||
|> Out.encode_short(item.count)
|
||||
|> Out.encode_short(position)
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms item moved from cash inventory to regular inventory.
|
||||
"""
|
||||
def confirm_from_cs_inventory(socket, item, position) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x69) # From CS inventory
|
||||
|> Out.encode_byte(Odinsea.Game.InventoryType.get_type(
|
||||
Odinsea.Game.InventoryType.from_item_id(item.item_id)
|
||||
))
|
||||
|> Out.encode_short(position)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms item moved to cash inventory.
|
||||
"""
|
||||
def confirm_to_cs_inventory(socket, item, account_id) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x5F) # To CS inventory
|
||||
|> Out.encode_int(account_id)
|
||||
|> encode_cash_item_single(item)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_cash_item_single(packet, item) do
|
||||
packet
|
||||
|> Out.encode_long(item.unique_id)
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_int(item.sn || 0)
|
||||
|> Out.encode_short(item.quantity)
|
||||
|> Out.encode_string(item.gift_from || "")
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Gift Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Sends gift confirmation.
|
||||
"""
|
||||
def send_gift(socket, price, item_id, count, partner) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x5B) # Gift sent operation
|
||||
|> Out.encode_int(price)
|
||||
|> Out.encode_int(item_id)
|
||||
|> Out.encode_short(count)
|
||||
|> Out.encode_string(partner)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets gifts for the player.
|
||||
"""
|
||||
def get_cs_gifts(socket, gifts) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x48) # Gifts operation
|
||||
|> Out.encode_short(length(gifts))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(gifts, pkt, fn {item, msg}, p ->
|
||||
p
|
||||
|> Out.encode_int(item.sn)
|
||||
|> Out.encode_string(item.gift_from)
|
||||
|> Out.encode_string(msg)
|
||||
|> encode_cash_item_single(item)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Wishlist Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Sends the wishlist to the player.
|
||||
"""
|
||||
def send_wishlist(socket, _character, wishlist) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x4D) # Wishlist operation
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(wishlist, pkt, fn sn, p ->
|
||||
Out.encode_int(p, sn)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Coupon Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Shows coupon redemption result.
|
||||
"""
|
||||
def show_coupon_redeemed(socket, items, maple_points, mesos, client_state) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x4B) # Coupon operation
|
||||
|> Out.encode_byte(if safe_map_size(items) > 0, do: 1, else: 0)
|
||||
|> Out.encode_int(safe_map_size(items))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn {_sn, item}, p ->
|
||||
encode_coupon_item(p, item, client_state)
|
||||
end)
|
||||
end)
|
||||
|> Out.encode_int(mesos)
|
||||
|> Out.encode_int(maple_points)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_coupon_item(packet, item, _client_state) do
|
||||
packet
|
||||
|> Out.encode_int(item.sn)
|
||||
|> Out.encode_byte(0) # Unknown
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_int(item.count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Redeem response (simplified).
|
||||
"""
|
||||
def redeem_response(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0xA1) # Redeem response
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Error Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Sends a cash shop failure code.
|
||||
"""
|
||||
def send_cs_fail(socket, code) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x49) # Fail operation
|
||||
|> Out.encode_byte(code)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Notifies that a cash item has expired.
|
||||
"""
|
||||
def cash_item_expired(socket, unique_id) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_cash_shop_update())
|
||||
|> Out.encode_byte(0x4E) # Expired operation
|
||||
|> Out.encode_long(unique_id)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# MTS Packets
|
||||
# ==============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the MTS for a player.
|
||||
"""
|
||||
def start_mts(character) do
|
||||
Out.new(Opcodes.lp_set_mts_opened())
|
||||
|> encode_mts_info(character)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp encode_mts_info(packet, character) do
|
||||
packet
|
||||
|> encode_mts_tax_rates()
|
||||
|> encode_character_mts_info(character)
|
||||
end
|
||||
|
||||
defp encode_mts_tax_rates(packet) do
|
||||
# Tax rates for different price ranges
|
||||
packet
|
||||
|> Out.encode_int(5) # Number of brackets
|
||||
# Bracket 1: 0-5000000
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(5_000_000)
|
||||
|> Out.encode_int(10) # 10% tax
|
||||
# Bracket 2: 5000000-10000000
|
||||
|> Out.encode_int(5_000_001)
|
||||
|> Out.encode_int(10_000_000)
|
||||
|> Out.encode_int(9)
|
||||
# Bracket 3: 10000000-50000000
|
||||
|> Out.encode_int(10_000_001)
|
||||
|> Out.encode_int(50_000_000)
|
||||
|> Out.encode_int(8)
|
||||
# Bracket 4: 50000000-100000000
|
||||
|> Out.encode_int(50_000_001)
|
||||
|> Out.encode_int(100_000_000)
|
||||
|> Out.encode_int(7)
|
||||
# Bracket 5: 100000000+
|
||||
|> Out.encode_int(100_000_001)
|
||||
|> Out.encode_int(999_999_999)
|
||||
|> Out.encode_int(6)
|
||||
end
|
||||
|
||||
defp encode_character_mts_info(packet, character) do
|
||||
packet
|
||||
|> Out.encode_int(character.nx_cash || 0)
|
||||
|> Out.encode_int(character.maple_points || 0)
|
||||
|> Out.encode_int(character.id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows MTS cash balance.
|
||||
"""
|
||||
def show_mts_cash(socket, character) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x17) # Show cash
|
||||
|> Out.encode_int(character.nx_cash || 0)
|
||||
|> Out.encode_int(character.maple_points || 0)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends current MTS listings.
|
||||
"""
|
||||
def send_current_mts(socket, cart) do
|
||||
items = Odinsea.Shop.MTS.get_current_mts(cart)
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x16) # Current MTS
|
||||
|> Out.encode_int(length(items))
|
||||
|> Out.encode_int(0) # Total count (for pagination)
|
||||
|> Out.encode_int(cart.page)
|
||||
|> Out.encode_int(cart.tab)
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn item, p ->
|
||||
encode_mts_item(p, item)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends "not yet sold" listings.
|
||||
"""
|
||||
def send_not_yet_sold(socket, cart) do
|
||||
items = Odinsea.Shop.MTS.get_not_yet_sold(cart.character_id)
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x18) # Not yet sold
|
||||
|> Out.encode_int(length(items))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn item, p ->
|
||||
encode_mts_item(p, item)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends transfer inventory.
|
||||
"""
|
||||
def send_transfer(socket, cart, changed \\ false) do
|
||||
items = Odinsea.Shop.MTS.get_transfer(cart.character_id)
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x19) # Transfer
|
||||
|> Out.encode_byte(if changed, do: 1, else: 0)
|
||||
|> Out.encode_int(length(items))
|
||||
|> then(fn pkt ->
|
||||
Enum.reduce(items, pkt, fn item, p ->
|
||||
encode_mts_transfer_item(p, item)
|
||||
end)
|
||||
end)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
defp encode_mts_item(packet, item) do
|
||||
packet
|
||||
|> Out.encode_int(item.id)
|
||||
|> Out.encode_int(item.item.item_id)
|
||||
|> Out.encode_int(item.price)
|
||||
|> Out.encode_int(item.price) # Current price (can change)
|
||||
|> Out.encode_int(item.seller_id)
|
||||
|> Out.encode_string(item.seller_name)
|
||||
|> Out.encode_long(item.expiration)
|
||||
|> encode_item_stats(item.item)
|
||||
end
|
||||
|
||||
defp encode_mts_transfer_item(packet, item) do
|
||||
packet
|
||||
|> Out.encode_int(item.item_id)
|
||||
|> Out.encode_short(item.quantity)
|
||||
|> encode_item_stats(item)
|
||||
end
|
||||
|
||||
defp encode_item_stats(packet, item) do
|
||||
# Full item encoding with stats
|
||||
# This is simplified - full version would encode equipment stats
|
||||
packet
|
||||
|> Out.encode_byte(0) # Has stats flag
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS wanted listing over.
|
||||
"""
|
||||
def get_mts_wanted_listing_over(socket, nx, maple_points) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x1A)
|
||||
|> Out.encode_int(nx)
|
||||
|> Out.encode_int(maple_points)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS confirm sell.
|
||||
"""
|
||||
def get_mts_confirm_sell(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x02)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS fail sell.
|
||||
"""
|
||||
def get_mts_fail_sell(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x03)
|
||||
|> Out.encode_byte(0) # Error code
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS confirm cancel.
|
||||
"""
|
||||
def get_mts_confirm_cancel(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x08)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS fail cancel.
|
||||
"""
|
||||
def get_mts_fail_cancel(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x09)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS confirm buy.
|
||||
"""
|
||||
def get_mts_confirm_buy(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x0C)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS fail buy.
|
||||
"""
|
||||
def get_mts_fail_buy(socket) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x0D)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
MTS confirm transfer.
|
||||
"""
|
||||
def get_mts_confirm_transfer(socket, inv_type, position) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x11)
|
||||
|> Out.encode_byte(inv_type)
|
||||
|> Out.encode_short(position)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add to cart message.
|
||||
"""
|
||||
def add_to_cart_message(socket, failed, deleted) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_mts_operation())
|
||||
|> Out.encode_byte(0x15)
|
||||
|> Out.encode_byte(if failed, do: 0, else: 1)
|
||||
|> Out.encode_byte(if deleted, do: 0, else: 1)
|
||||
|> Out.to_data()
|
||||
|
||||
:gen_tcp.send(socket, packet)
|
||||
end
|
||||
|
||||
# ==============================================================================
|
||||
# Utility Functions
|
||||
# ==============================================================================
|
||||
|
||||
defp safe_map_size(map) when is_map(map), do: Kernel.map_size(map)
|
||||
defp safe_map_size(_), do: 0
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,917 @@
|
||||
defmodule Odinsea.World.Guild do
|
||||
@moduledoc """
|
||||
Guild management service.
|
||||
Ported from src/handling/world/guild/MapleGuild.java
|
||||
|
||||
Manages guild state including members, ranks, skills, and alliance.
|
||||
Supports guild creation, joining, leaving, and rank management.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Repo
|
||||
import Ecto.Query
|
||||
|
||||
@default_capacity 10
|
||||
@max_capacity 200
|
||||
@rank_titles ["Master", "Jr. Master", "Member", "Member", "Member"]
|
||||
@create_cost 500_000
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule GuildCharacter do
|
||||
@moduledoc "Guild member representation"
|
||||
defstruct [
|
||||
:id, :name, :level, :job, :channel,
|
||||
:guild_rank, :alliance_rank, :guild_contribution,
|
||||
:online
|
||||
]
|
||||
end
|
||||
|
||||
defmodule GuildSkill do
|
||||
@moduledoc "Guild skill representation"
|
||||
defstruct [
|
||||
:skill_id, :level, :timestamp, :purchaser, :activators
|
||||
]
|
||||
end
|
||||
|
||||
defmodule BBSThread do
|
||||
@moduledoc "Guild BBS thread"
|
||||
defstruct [
|
||||
:thread_id, :local_id, :name, :content,
|
||||
:poster_id, :timestamp, :icon, :replies
|
||||
]
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new guild.
|
||||
Returns {:ok, guild_id} on success, {:error, reason} on failure.
|
||||
"""
|
||||
def create_guild(leader_id, name) do
|
||||
GenServer.call(__MODULE__, {:create_guild, leader_id, name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a guild by ID.
|
||||
Returns the guild struct or nil if not found.
|
||||
"""
|
||||
def get_guild(guild_id) do
|
||||
GenServer.call(__MODULE__, {:get_guild, guild_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets guild by member character ID.
|
||||
"""
|
||||
def get_guild_by_character(character_id) do
|
||||
GenServer.call(__MODULE__, {:get_guild_by_character, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a member to a guild.
|
||||
"""
|
||||
def add_member(guild_id, character) do
|
||||
GenServer.call(__MODULE__, {:add_member, guild_id, character})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a member from a guild (leave).
|
||||
"""
|
||||
def leave_guild(guild_id, character_id) do
|
||||
GenServer.call(__MODULE__, {:leave_guild, guild_id, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Expels a member from a guild.
|
||||
"""
|
||||
def expel_member(guild_id, expeller_id, target_id, target_name) do
|
||||
GenServer.call(__MODULE__, {:expel_member, guild_id, expeller_id, target_id, target_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes a member's guild rank.
|
||||
"""
|
||||
def change_rank(guild_id, character_id, new_rank, changer_id) do
|
||||
GenServer.call(__MODULE__, {:change_rank, guild_id, character_id, new_rank, changer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes guild rank titles.
|
||||
"""
|
||||
def change_rank_titles(guild_id, titles, changer_id) do
|
||||
GenServer.call(__MODULE__, {:change_rank_titles, guild_id, titles, changer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes the guild leader.
|
||||
"""
|
||||
def change_leader(guild_id, new_leader_id, current_leader_id) do
|
||||
GenServer.call(__MODULE__, {:change_leader, guild_id, new_leader_id, current_leader_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets guild emblem.
|
||||
"""
|
||||
def set_emblem(guild_id, bg, bg_color, logo, logo_color, changer_id) do
|
||||
GenServer.call(__MODULE__, {:set_emblem, guild_id, bg, bg_color, logo, logo_color, changer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets guild notice.
|
||||
"""
|
||||
def set_notice(guild_id, notice, changer_id) do
|
||||
GenServer.call(__MODULE__, {:set_notice, guild_id, notice, changer_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increases guild capacity.
|
||||
"""
|
||||
def increase_capacity(guild_id, leader_id, true_max \\ false) do
|
||||
GenServer.call(__MODULE__, {:increase_capacity, guild_id, leader_id, true_max})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gains guild points (GP).
|
||||
"""
|
||||
def gain_gp(guild_id, amount, character_id \\ nil, broadcast \\ true) do
|
||||
GenServer.call(__MODULE__, {:gain_gp, guild_id, amount, character_id, broadcast})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets member online status.
|
||||
"""
|
||||
def set_online(guild_id, character_id, online, channel) do
|
||||
GenServer.call(__MODULE__, {:set_online, guild_id, character_id, online, channel})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates member info (level/job change).
|
||||
"""
|
||||
def update_member(guild_id, character) do
|
||||
GenServer.call(__MODULE__, {:update_member, guild_id, character})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disbands a guild.
|
||||
"""
|
||||
def disband_guild(guild_id, leader_id) do
|
||||
GenServer.call(__MODULE__, {:disband_guild, guild_id, leader_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets guild skills.
|
||||
"""
|
||||
def get_skills(guild_id) do
|
||||
GenServer.call(__MODULE__, {:get_skills, guild_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Purchases a guild skill.
|
||||
"""
|
||||
def purchase_skill(guild_id, skill_id, purchaser_name, purchaser_id) do
|
||||
GenServer.call(__MODULE__, {:purchase_skill, guild_id, skill_id, purchaser_name, purchaser_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Activates a guild skill.
|
||||
"""
|
||||
def activate_skill(guild_id, skill_id, activator_name) do
|
||||
GenServer.call(__MODULE__, {:activate_skill, guild_id, skill_id, activator_name})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all online guild members.
|
||||
"""
|
||||
def broadcast(guild_id, packet, except_character_id \\ nil) do
|
||||
GenServer.cast(__MODULE__, {:broadcast, guild_id, packet, except_character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Guild chat - sends message to all online guild members.
|
||||
"""
|
||||
def guild_chat(guild_id, sender_name, sender_id, message) do
|
||||
GenServer.cast(__MODULE__, {:guild_chat, guild_id, sender_name, sender_id, message})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets alliance ID for a guild.
|
||||
"""
|
||||
def set_alliance(guild_id, alliance_id, alliance_rank) do
|
||||
GenServer.call(__MODULE__, {:set_alliance, guild_id, alliance_id, alliance_rank})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{guilds: %{}}}
|
||||
# Load guilds from database on startup
|
||||
guilds = load_guilds_from_db()
|
||||
|
||||
Logger.info("Guild service initialized with #{map_size(guilds)} guilds")
|
||||
{:ok, %{guilds: guilds}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:create_guild, leader_id, name}, _from, state) do
|
||||
# Validate name
|
||||
cond do
|
||||
String.length(name) > 12 ->
|
||||
{:reply, {:error, :name_too_long}, state}
|
||||
|
||||
String.length(name) < 3 ->
|
||||
{:reply, {:error, :name_too_short}, state}
|
||||
|
||||
not valid_guild_name?(name) ->
|
||||
{:reply, {:error, :invalid_name}, state}
|
||||
|
||||
true ->
|
||||
case create_guild_in_db(leader_id, name) do
|
||||
{:ok, guild_id} ->
|
||||
guild = create_new_guild_struct(guild_id, leader_id, name)
|
||||
new_state = %{state | guilds: Map.put(state.guilds, guild_id, guild)}
|
||||
|
||||
Logger.info("Guild '#{name}' (ID: #{guild_id}) created by leader #{leader_id}")
|
||||
{:reply, {:ok, guild_id}, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_guild, guild_id}, _from, state) do
|
||||
{:reply, Map.get(state.guilds, guild_id), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_guild_by_character, character_id}, _from, state) do
|
||||
guild = state.guilds
|
||||
|> Map.values()
|
||||
|> Enum.find(fn g ->
|
||||
Enum.any?(g.members, fn m -> m.id == character_id end)
|
||||
end)
|
||||
|
||||
{:reply, guild, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_member, guild_id, character}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
if length(guild.members) >= guild.capacity do
|
||||
{:reply, {:error, :guild_full}, state}
|
||||
else
|
||||
# Create new member with rank 5 (lowest)
|
||||
member = %GuildCharacter{
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
level: character.level,
|
||||
job: character.job,
|
||||
channel: character.channel_id || 1,
|
||||
guild_rank: 5,
|
||||
alliance_rank: if(guild.alliance_id > 0, do: 3, else: 0),
|
||||
guild_contribution: 0,
|
||||
online: true
|
||||
}
|
||||
|
||||
updated_guild = %{guild | members: guild.members ++ [member]}
|
||||
|
||||
# Save to database
|
||||
save_member_to_db(guild_id, member)
|
||||
|
||||
# Broadcast new member to guild
|
||||
broadcast_new_member(updated_guild, member)
|
||||
|
||||
# Gain GP for new member
|
||||
updated_guild = %{updated_guild | gp: guild.gp + 500}
|
||||
|
||||
{:reply, {:ok, member}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:leave_guild, guild_id, character_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
member = Enum.find(guild.members, fn m -> m.id == character_id end)
|
||||
|
||||
if member do
|
||||
# Remove member
|
||||
members = Enum.reject(guild.members, fn m -> m.id == character_id end)
|
||||
|
||||
# If leader leaves and there are members, promote next highest rank
|
||||
updated_guild = if guild.leader_id == character_id && length(members) > 0 do
|
||||
# Find highest ranked member (lowest rank number)
|
||||
new_leader = Enum.min_by(members, fn m -> m.guild_rank end)
|
||||
%{guild | members: members, leader_id: new_leader.id}
|
||||
else
|
||||
%{guild | members: members}
|
||||
end
|
||||
|
||||
# Remove from database
|
||||
remove_member_from_db(guild_id, character_id)
|
||||
|
||||
# Broadcast member left
|
||||
broadcast_member_left(updated_guild, member)
|
||||
|
||||
# Deduct GP
|
||||
gp_loss = if member.guild_contribution > 0, do: -member.guild_contribution, else: -50
|
||||
updated_guild = %{updated_guild | gp: max(0, guild.gp + gp_loss)}
|
||||
|
||||
# If no members left, mark for disband
|
||||
final_state = if length(members) == 0 do
|
||||
disband_guild_in_db(guild_id)
|
||||
%{state | guilds: Map.delete(state.guilds, guild_id)}
|
||||
else
|
||||
%{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}
|
||||
end
|
||||
|
||||
{:reply, :ok, final_state}
|
||||
else
|
||||
{:reply, {:error, :not_in_guild}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:expel_member, guild_id, expeller_id, target_id, target_name}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
expeller = Enum.find(guild.members, fn m -> m.id == expeller_id end)
|
||||
target = Enum.find(guild.members, fn m -> m.id == target_id end)
|
||||
|
||||
cond do
|
||||
not expeller || expeller.guild_rank > 2 ->
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
|
||||
not target || target.guild_rank <= expeller.guild_rank ->
|
||||
{:reply, {:error, :cannot_expel}, state}
|
||||
|
||||
true ->
|
||||
# Remove member
|
||||
members = Enum.reject(guild.members, fn m -> m.id == target_id end)
|
||||
updated_guild = %{guild | members: members}
|
||||
|
||||
# Remove from database
|
||||
remove_member_from_db(guild_id, target_id)
|
||||
|
||||
# Send note if offline
|
||||
unless target.online do
|
||||
send_note(target_name, expeller.name, "You have been expelled from the guild.")
|
||||
end
|
||||
|
||||
# Broadcast
|
||||
broadcast_member_expelled(updated_guild, target)
|
||||
|
||||
# Deduct GP
|
||||
gp_loss = if target.guild_contribution > 0, do: -target.guild_contribution, else: -50
|
||||
updated_guild = %{updated_guild | gp: max(0, guild.gp + gp_loss)}
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_rank, guild_id, character_id, new_rank, changer_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
changer = Enum.find(guild.members, fn m -> m.id == changer_id end)
|
||||
target = Enum.find(guild.members, fn m -> m.id == character_id end)
|
||||
|
||||
cond do
|
||||
not changer || changer.guild_rank > 2 ->
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
|
||||
new_rank <= 1 || new_rank > 5 ->
|
||||
{:reply, {:error, :invalid_rank}, state}
|
||||
|
||||
new_rank <= 2 && changer.guild_rank != 1 ->
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
|
||||
not target ->
|
||||
{:reply, {:error, :member_not_found}, state}
|
||||
|
||||
true ->
|
||||
# Update rank
|
||||
members = Enum.map(guild.members, fn m ->
|
||||
if m.id == character_id do
|
||||
%{m | guild_rank: new_rank}
|
||||
else
|
||||
m
|
||||
end
|
||||
end)
|
||||
|
||||
updated_guild = %{guild | members: members}
|
||||
|
||||
# Save to database
|
||||
update_rank_in_db(character_id, new_rank)
|
||||
|
||||
# Broadcast
|
||||
broadcast_rank_changed(updated_guild, target, new_rank)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_rank_titles, guild_id, titles, changer_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
changer = Enum.find(guild.members, fn m -> m.id == changer_id end)
|
||||
|
||||
if not changer || changer.guild_rank != 1 do
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
else
|
||||
updated_guild = %{guild | rank_titles: titles}
|
||||
|
||||
# Save to database
|
||||
update_rank_titles_in_db(guild_id, titles)
|
||||
|
||||
# Broadcast
|
||||
broadcast_rank_titles_changed(updated_guild, titles)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_leader, guild_id, new_leader_id, current_leader_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
%{leader_id: actual_leader} when actual_leader != current_leader_id ->
|
||||
{:reply, {:error, :not_leader}, state}
|
||||
|
||||
guild ->
|
||||
unless Enum.any?(guild.members, fn m -> m.id == new_leader_id end) do
|
||||
{:reply, {:error, :not_in_guild}, state}
|
||||
else
|
||||
# Update ranks: new leader -> 1, old leader -> 2
|
||||
members = Enum.map(guild.members, fn m ->
|
||||
cond do
|
||||
m.id == new_leader_id -> %{m | guild_rank: 1}
|
||||
m.id == current_leader_id -> %{m | guild_rank: 2}
|
||||
true -> m
|
||||
end
|
||||
end)
|
||||
|
||||
updated_guild = %{guild | members: members, leader_id: new_leader_id}
|
||||
|
||||
# Save to database
|
||||
update_leader_in_db(guild_id, new_leader_id)
|
||||
update_rank_in_db(new_leader_id, 1)
|
||||
update_rank_in_db(current_leader_id, 2)
|
||||
|
||||
# Broadcast
|
||||
broadcast_leader_changed(updated_guild, new_leader_id)
|
||||
|
||||
Logger.info("Guild #{guild_id} leader changed from #{current_leader_id} to #{new_leader_id}")
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_emblem, guild_id, bg, bg_color, logo, logo_color, changer_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
changer = Enum.find(guild.members, fn m -> m.id == changer_id end)
|
||||
|
||||
if not changer || changer.guild_rank != 1 do
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
else
|
||||
updated_guild = %{guild |
|
||||
logo_bg: bg,
|
||||
logo_bg_color: bg_color,
|
||||
logo: logo,
|
||||
logo_color: logo_color
|
||||
}
|
||||
|
||||
# Save to database
|
||||
update_emblem_in_db(guild_id, bg, bg_color, logo, logo_color)
|
||||
|
||||
# Broadcast
|
||||
broadcast_emblem_changed(updated_guild)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_notice, guild_id, notice, changer_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
changer = Enum.find(guild.members, fn m -> m.id == changer_id end)
|
||||
|
||||
if not changer || changer.guild_rank > 2 do
|
||||
{:reply, {:error, :no_permission}, state}
|
||||
else
|
||||
updated_guild = %{guild | notice: String.slice(notice, 0, 100)}
|
||||
|
||||
# Save to database
|
||||
update_notice_in_db(guild_id, updated_guild.notice)
|
||||
|
||||
# Broadcast
|
||||
broadcast_notice_changed(updated_guild)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:increase_capacity, guild_id, leader_id, true_max}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
max_cap = if true_max, do: @max_capacity, else: div(@max_capacity, 2)
|
||||
|
||||
cond do
|
||||
guild.leader_id != leader_id ->
|
||||
{:reply, {:error, :not_leader}, state}
|
||||
|
||||
guild.capacity >= max_cap ->
|
||||
{:reply, {:error, :max_capacity}, state}
|
||||
|
||||
true ->
|
||||
new_capacity = min(guild.capacity + 5, max_cap)
|
||||
updated_guild = %{guild | capacity: new_capacity}
|
||||
|
||||
# Save to database
|
||||
update_capacity_in_db(guild_id, new_capacity)
|
||||
|
||||
# Broadcast
|
||||
broadcast_capacity_changed(updated_guild)
|
||||
|
||||
{:reply, {:ok, new_capacity}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:gain_gp, guild_id, amount, _character_id, broadcast}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
new_gp = max(0, guild.gp + amount)
|
||||
updated_guild = %{guild | gp: new_gp}
|
||||
|
||||
# Save to database
|
||||
update_gp_in_db(guild_id, new_gp)
|
||||
|
||||
# Optionally broadcast
|
||||
if broadcast do
|
||||
broadcast_gp_changed(updated_guild, amount)
|
||||
end
|
||||
|
||||
{:reply, {:ok, new_gp}, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_online, guild_id, character_id, online, channel}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
members = Enum.map(guild.members, fn m ->
|
||||
if m.id == character_id do
|
||||
%{m | online: online, channel: if(online, do: channel, else: -1)}
|
||||
else
|
||||
m
|
||||
end
|
||||
end)
|
||||
|
||||
updated_guild = %{guild | members: members}
|
||||
|
||||
# Broadcast online status to other members
|
||||
broadcast_member_online(updated_guild, character_id, online)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_member, guild_id, character}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
members = Enum.map(guild.members, fn m ->
|
||||
if m.id == character.id do
|
||||
%{m |
|
||||
level: character.level || m.level,
|
||||
job: character.job || m.job,
|
||||
channel: character.channel_id || m.channel
|
||||
}
|
||||
else
|
||||
m
|
||||
end
|
||||
end)
|
||||
|
||||
updated_guild = %{guild | members: members}
|
||||
|
||||
# Broadcast level/job change if applicable
|
||||
broadcast_member_info_updated(updated_guild, character)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:disband_guild, guild_id, leader_id}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
%{leader_id: actual_leader} when actual_leader != leader_id ->
|
||||
{:reply, {:error, :not_leader}, state}
|
||||
|
||||
guild ->
|
||||
# Broadcast disband
|
||||
broadcast_guild_disband(guild)
|
||||
|
||||
# Remove from database
|
||||
disband_guild_in_db(guild_id)
|
||||
|
||||
Logger.info("Guild #{guild_id} (#{guild.name}) disbanded")
|
||||
{:reply, :ok, %{state | guilds: Map.delete(state.guilds, guild_id)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_alliance, guild_id, alliance_id, alliance_rank}, _from, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :guild_not_found}, state}
|
||||
|
||||
guild ->
|
||||
members = Enum.map(guild.members, fn m ->
|
||||
%{m | alliance_rank: alliance_rank}
|
||||
end)
|
||||
|
||||
updated_guild = %{guild | alliance_id: alliance_id, members: members}
|
||||
|
||||
# Save to database
|
||||
update_alliance_in_db(guild_id, alliance_id, alliance_rank)
|
||||
|
||||
{:reply, :ok, %{state | guilds: Map.put(state.guilds, guild_id, updated_guild)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, guild_id, packet, except_id}, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil -> :ok
|
||||
guild ->
|
||||
Enum.each(guild.members, fn member ->
|
||||
if member.online && member.id != except_id do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] -> send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:guild_chat, guild_id, sender_name, sender_id, message}, state) do
|
||||
case Map.get(state.guilds, guild_id) do
|
||||
nil -> :ok
|
||||
guild ->
|
||||
Enum.each(guild.members, fn member ->
|
||||
if member.online && member.id != sender_id do
|
||||
# Check blacklist
|
||||
# TODO: Implement blacklist check
|
||||
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] ->
|
||||
packet = build_guild_chat_packet(sender_name, message)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Database Functions (Stub implementations - would use Ecto)
|
||||
# ============================================================================
|
||||
|
||||
defp load_guilds_from_db do
|
||||
# TODO: Implement actual database loading
|
||||
# For now, return empty map
|
||||
%{}
|
||||
end
|
||||
|
||||
defp create_guild_in_db(leader_id, name) do
|
||||
# TODO: Implement database insert
|
||||
# Return a new guild ID
|
||||
{:ok, System.unique_integer([:positive])}
|
||||
end
|
||||
|
||||
defp save_member_to_db(_guild_id, _member) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp remove_member_from_db(_guild_id, _character_id) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_rank_in_db(_character_id, _rank) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_leader_in_db(_guild_id, _leader_id) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_rank_titles_in_db(_guild_id, _titles) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_emblem_in_db(_guild_id, _bg, _bg_color, _logo, _logo_color) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_notice_in_db(_guild_id, _notice) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_capacity_in_db(_guild_id, _capacity) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_gp_in_db(_guild_id, _gp) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp update_alliance_in_db(_guild_id, _alliance_id, _alliance_rank) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp disband_guild_in_db(_guild_id) do
|
||||
# TODO: Implement
|
||||
:ok
|
||||
end
|
||||
|
||||
defp send_note(_to_name, _from_name, _message) do
|
||||
# TODO: Implement note sending
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp valid_guild_name?(name) do
|
||||
# Only allow letters
|
||||
Regex.match?(~r/^[a-zA-Z]+$/, name)
|
||||
end
|
||||
|
||||
defp create_new_guild_struct(guild_id, leader_id, name) do
|
||||
%{
|
||||
id: guild_id,
|
||||
name: name,
|
||||
leader_id: leader_id,
|
||||
gp: 0,
|
||||
logo: 0,
|
||||
logo_color: 0,
|
||||
logo_bg: 0,
|
||||
logo_bg_color: 0,
|
||||
capacity: @default_capacity,
|
||||
rank_titles: @rank_titles,
|
||||
notice: "",
|
||||
signature: System.system_time(:second),
|
||||
alliance_id: 0,
|
||||
members: [],
|
||||
skills: %{},
|
||||
bbs: %{}
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Broadcast Functions
|
||||
# ============================================================================
|
||||
|
||||
defp broadcast_new_member(guild, member) do
|
||||
# TODO: Implement packet
|
||||
Logger.debug("Broadcast new member #{member.name} to guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_member_left(guild, member) do
|
||||
Logger.debug("Broadcast member left #{member.name} to guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_member_expelled(guild, member) do
|
||||
Logger.debug("Broadcast member expelled #{member.name} to guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_rank_changed(guild, member, new_rank) do
|
||||
Logger.debug("Broadcast rank change for #{member.name} to #{new_rank} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_rank_titles_changed(guild, titles) do
|
||||
Logger.debug("Broadcast rank titles changed in guild #{guild.id}: #{inspect(titles)}")
|
||||
end
|
||||
|
||||
defp broadcast_leader_changed(guild, new_leader_id) do
|
||||
Logger.debug("Broadcast leader changed to #{new_leader_id} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_emblem_changed(guild) do
|
||||
Logger.debug("Broadcast emblem changed in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_notice_changed(guild) do
|
||||
Logger.debug("Broadcast notice changed in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_capacity_changed(guild) do
|
||||
Logger.debug("Broadcast capacity changed to #{guild.capacity} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_gp_changed(guild, amount) do
|
||||
Logger.debug("Broadcast GP change #{amount} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_member_online(guild, character_id, online) do
|
||||
Logger.debug("Broadcast member #{character_id} online=#{online} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_member_info_updated(guild, character) do
|
||||
Logger.debug("Broadcast member info update for #{character.id} in guild #{guild.id}")
|
||||
end
|
||||
|
||||
defp broadcast_guild_disband(guild) do
|
||||
Logger.debug("Broadcast guild disband for #{guild.id}")
|
||||
end
|
||||
|
||||
defp build_guild_chat_packet(_sender_name, _message) do
|
||||
# TODO: Implement proper packet
|
||||
<<>>
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,543 @@
|
||||
defmodule Odinsea.World.Party do
|
||||
@moduledoc """
|
||||
Party management service.
|
||||
Ported from src/handling/world/MapleParty.java
|
||||
|
||||
Manages party state including members, leader, and operations.
|
||||
Supports cross-channel party functionality.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@max_party_size 6
|
||||
@loot_rules [:free_for_all, :round_robin, :master, :master_looter]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new party with the given leader character.
|
||||
Returns {:ok, party_id} on success, {:error, reason} on failure.
|
||||
"""
|
||||
def create_party(leader_character) do
|
||||
GenServer.call(__MODULE__, {:create_party, leader_character})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a party linked to an expedition.
|
||||
"""
|
||||
def create_expedition_party(leader_character, expedition_id) do
|
||||
GenServer.call(__MODULE__, {:create_expedition_party, leader_character, expedition_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a party by ID.
|
||||
Returns the party struct or nil if not found.
|
||||
"""
|
||||
def get_party(party_id) do
|
||||
GenServer.call(__MODULE__, {:get_party, party_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates party with a member operation (join, leave, expel, etc.).
|
||||
"""
|
||||
def update_party(party_id, operation, character) do
|
||||
GenServer.call(__MODULE__, {:update_party, party_id, operation, character})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disbands a party.
|
||||
"""
|
||||
def disband_party(party_id, leader_id) do
|
||||
GenServer.call(__MODULE__, {:disband_party, party_id, leader_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes the party leader.
|
||||
"""
|
||||
def change_leader(party_id, new_leader_id, current_leader_id) do
|
||||
GenServer.call(__MODULE__, {:change_leader, party_id, new_leader_id, current_leader_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets a character's online status in the party.
|
||||
"""
|
||||
def set_online(party_id, character_id, online, channel) do
|
||||
GenServer.call(__MODULE__, {:set_online, party_id, character_id, online, channel})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates character info (level, job, map) for a party member.
|
||||
"""
|
||||
def update_member(party_id, character) do
|
||||
GenServer.call(__MODULE__, {:update_member, party_id, character})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all parties (for admin/debug purposes).
|
||||
"""
|
||||
def get_all_parties do
|
||||
GenServer.call(__MODULE__, :get_all_parties)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets party members for broadcasting.
|
||||
Returns list of {character_id, channel} tuples.
|
||||
"""
|
||||
def get_member_channels(party_id) do
|
||||
GenServer.call(__MODULE__, {:get_member_channels, party_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all party members except the sender.
|
||||
"""
|
||||
def broadcast_to_party(party_id, packet, except_character_id \\ nil) do
|
||||
GenServer.cast(__MODULE__, {:broadcast_to_party, party_id, packet, except_character_id})
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{parties: %{}, next_id: 1}}
|
||||
state = %{
|
||||
parties: %{},
|
||||
next_id: 1
|
||||
}
|
||||
|
||||
Logger.info("Party service initialized")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:create_party, leader}, _from, state) do
|
||||
party_id = state.next_id
|
||||
|
||||
party = %{
|
||||
id: party_id,
|
||||
leader_id: leader.id,
|
||||
members: [create_party_character(leader)],
|
||||
expedition_id: -1,
|
||||
disbanded: false,
|
||||
loot_rule: :free_for_all,
|
||||
created_at: System.system_time(:second)
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| parties: Map.put(state.parties, party_id, party),
|
||||
next_id: party_id + 1
|
||||
}
|
||||
|
||||
Logger.info("Party #{party_id} created by #{leader.name}")
|
||||
{:reply, {:ok, party}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:create_expedition_party, leader, expedition_id}, _from, state) do
|
||||
party_id = state.next_id
|
||||
|
||||
party = %{
|
||||
id: party_id,
|
||||
leader_id: leader.id,
|
||||
members: [create_party_character(leader)],
|
||||
expedition_id: expedition_id,
|
||||
disbanded: false,
|
||||
loot_rule: :free_for_all,
|
||||
created_at: System.system_time(:second)
|
||||
}
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| parties: Map.put(state.parties, party_id, party),
|
||||
next_id: party_id + 1
|
||||
}
|
||||
|
||||
Logger.info("Expedition party #{party_id} created for expedition #{expedition_id}")
|
||||
{:reply, {:ok, party}, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_party, party_id}, _from, state) do
|
||||
party = Map.get(state.parties, party_id)
|
||||
|
||||
# Don't return disbanded parties
|
||||
if party && party.disbanded do
|
||||
{:reply, nil, state}
|
||||
else
|
||||
{:reply, party, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_party, party_id, operation, character}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :party_not_found}, state}
|
||||
|
||||
party when party.disbanded ->
|
||||
{:reply, {:error, :party_disbanded}, state}
|
||||
|
||||
party ->
|
||||
case apply_operation(party, operation, character) do
|
||||
{:ok, updated_party, result} ->
|
||||
new_state = %{state | parties: Map.put(state.parties, party_id, updated_party)}
|
||||
|
||||
# Broadcast update to party members
|
||||
broadcast_party_update(updated_party, operation, character)
|
||||
|
||||
{:reply, {:ok, result}, new_state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:reply, {:error, reason}, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:disband_party, party_id, leader_id}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :party_not_found}, state}
|
||||
|
||||
%{leader_id: actual_leader} when actual_leader != leader_id ->
|
||||
{:reply, {:error, :not_leader}, state}
|
||||
|
||||
party ->
|
||||
updated_party = %{party | disbanded: true, members: []}
|
||||
|
||||
# Notify all members
|
||||
broadcast_party_disband(party)
|
||||
|
||||
# Remove from state after a delay (for cleanup)
|
||||
:timer.apply_after(60_000, __MODULE__, :cleanup_party, [party_id])
|
||||
|
||||
Logger.info("Party #{party_id} disbanded by leader #{leader_id}")
|
||||
{:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_leader, party_id, new_leader_id, current_leader_id}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :party_not_found}, state}
|
||||
|
||||
%{leader_id: actual_leader} when actual_leader != current_leader_id ->
|
||||
{:reply, {:error, :not_leader}, state}
|
||||
|
||||
party ->
|
||||
# Check if new leader is in party
|
||||
unless Enum.any?(party.members, fn m -> m.id == new_leader_id end) do
|
||||
{:reply, {:error, :not_in_party}, state}
|
||||
else
|
||||
updated_party = %{party | leader_id: new_leader_id}
|
||||
|
||||
broadcast_leader_changed(updated_party, new_leader_id)
|
||||
|
||||
Logger.info("Party #{party_id} leader changed to #{new_leader_id}")
|
||||
{:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:set_online, party_id, character_id, online, channel}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :party_not_found}, state}
|
||||
|
||||
party when party.disbanded ->
|
||||
{:reply, {:error, :party_disbanded}, state}
|
||||
|
||||
party ->
|
||||
members = Enum.map(party.members, fn member ->
|
||||
if member.id == character_id do
|
||||
%{member | online: online, channel: channel}
|
||||
else
|
||||
member
|
||||
end
|
||||
end)
|
||||
|
||||
updated_party = %{party | members: members}
|
||||
|
||||
# Broadcast online status change
|
||||
broadcast_member_online(updated_party, character_id, online)
|
||||
|
||||
{:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_member, party_id, character}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil ->
|
||||
{:reply, {:error, :party_not_found}, state}
|
||||
|
||||
party when party.disbanded ->
|
||||
{:reply, {:error, :party_disbanded}, state}
|
||||
|
||||
party ->
|
||||
members = Enum.map(party.members, fn member ->
|
||||
if member.id == character.id do
|
||||
update_party_character(member, character)
|
||||
else
|
||||
member
|
||||
end
|
||||
end)
|
||||
|
||||
updated_party = %{party | members: members}
|
||||
{:reply, :ok, %{state | parties: Map.put(state.parties, party_id, updated_party)}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_all_parties, _from, state) do
|
||||
active_parties = state.parties
|
||||
|> Map.values()
|
||||
|> Enum.reject(fn p -> p.disbanded end)
|
||||
|
||||
{:reply, active_parties, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_member_channels, party_id}, _from, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil -> {:reply, [], state}
|
||||
%{disbanded: true} -> {:reply, [], state}
|
||||
party ->
|
||||
channels = party.members
|
||||
|> Enum.filter(fn m -> m.online end)
|
||||
|> Enum.map(fn m -> {m.id, m.channel} end)
|
||||
{:reply, channels, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast_to_party, party_id, packet, except_id}, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
nil -> :ok
|
||||
%{disbanded: true} -> :ok
|
||||
party ->
|
||||
# Broadcast to all online members except sender
|
||||
Enum.each(party.members, fn member ->
|
||||
if member.online && member.id != except_id do
|
||||
# Get character PID and send packet
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] -> send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Party Operations
|
||||
# ============================================================================
|
||||
|
||||
defp apply_operation(party, :join, character) do
|
||||
if length(party.members) >= @max_party_size do
|
||||
{:error, :party_full}
|
||||
else
|
||||
# Check if already in party
|
||||
if Enum.any?(party.members, fn m -> m.id == character.id end) do
|
||||
{:error, :already_in_party}
|
||||
else
|
||||
party_char = create_party_character(character)
|
||||
updated_party = %{party | members: party.members ++ [party_char]}
|
||||
{:ok, updated_party, party_char}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_operation(party, :leave, character) do
|
||||
members = Enum.reject(party.members, fn m -> m.id == character.id end)
|
||||
|
||||
# If leader leaves and there are other members, promote next member
|
||||
updated_party = if party.leader_id == character.id && length(members) > 0 do
|
||||
[new_leader | _] = members
|
||||
%{party | members: members, leader_id: new_leader.id}
|
||||
else
|
||||
%{party | members: members}
|
||||
end
|
||||
|
||||
{:ok, updated_party, :ok}
|
||||
end
|
||||
|
||||
defp apply_operation(party, :expel, character) do
|
||||
# Only leader can expel
|
||||
members = Enum.reject(party.members, fn m -> m.id == character.id end)
|
||||
updated_party = %{party | members: members}
|
||||
{:ok, updated_party, :ok}
|
||||
end
|
||||
|
||||
defp apply_operation(party, :silent_update, character) do
|
||||
# Update member info without broadcasting
|
||||
members = Enum.map(party.members, fn member ->
|
||||
if member.id == character.id do
|
||||
update_party_character(member, character)
|
||||
else
|
||||
member
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, %{party | members: members}, :ok}
|
||||
end
|
||||
|
||||
defp apply_operation(party, :log_onoff, character) do
|
||||
members = Enum.map(party.members, fn member ->
|
||||
if member.id == character.id do
|
||||
%{member | online: character.online, channel: character.channel}
|
||||
else
|
||||
member
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, %{party | members: members}, :ok}
|
||||
end
|
||||
|
||||
defp apply_operation(_party, operation, _character) do
|
||||
{:error, {:unknown_operation, operation}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp create_party_character(character) do
|
||||
%{
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
level: character.level,
|
||||
job: character.job,
|
||||
channel: character.channel_id || 1,
|
||||
map_id: character.map_id || 100000000,
|
||||
online: true,
|
||||
# Door info for mystic door skill
|
||||
door_town: 999999999,
|
||||
door_target: 999999999,
|
||||
door_skill: 0,
|
||||
door_x: 0,
|
||||
door_y: 0
|
||||
}
|
||||
end
|
||||
|
||||
defp update_party_character(existing, character) do
|
||||
%{
|
||||
existing
|
||||
| level: character.level || existing.level,
|
||||
job: character.job || existing.job,
|
||||
channel: character.channel_id || existing.channel,
|
||||
map_id: character.map_id || existing.map_id
|
||||
}
|
||||
end
|
||||
|
||||
defp broadcast_party_update(party, operation, character) do
|
||||
# Build party update packet and broadcast to members
|
||||
Enum.each(party.members, fn member ->
|
||||
if member.online && member.id != character.id do
|
||||
# Send party update packet
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] ->
|
||||
packet = build_party_update_packet(party, operation, character)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp broadcast_party_disband(party) do
|
||||
Enum.each(party.members, fn member ->
|
||||
if member.online do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] ->
|
||||
packet = build_party_disband_packet(party.id)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp broadcast_leader_changed(party, new_leader_id) do
|
||||
Enum.each(party.members, fn member ->
|
||||
if member.online do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] ->
|
||||
packet = build_leader_change_packet(party.id, new_leader_id)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp broadcast_member_online(party, character_id, online) do
|
||||
Enum.each(party.members, fn member ->
|
||||
if member.online && member.id != character_id do
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, member.id) do
|
||||
[{pid, _}] ->
|
||||
packet = build_member_online_packet(party.id, character_id, online)
|
||||
send(pid, {:send_packet, packet})
|
||||
[] -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Packet Builders (to be implemented in Channel.Packets)
|
||||
# ============================================================================
|
||||
|
||||
defp build_party_update_packet(_party, _operation, _character) do
|
||||
# TODO: Implement party update packet
|
||||
# For now, return empty (needs proper packet structure)
|
||||
<<>>
|
||||
end
|
||||
|
||||
defp build_party_disband_packet(_party_id) do
|
||||
# TODO: Implement party disband packet
|
||||
<<>>
|
||||
end
|
||||
|
||||
defp build_leader_change_packet(_party_id, _new_leader_id) do
|
||||
# TODO: Implement leader change packet
|
||||
<<>>
|
||||
end
|
||||
|
||||
defp build_member_online_packet(_party_id, _character_id, _online) do
|
||||
# TODO: Implement member online packet
|
||||
<<>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleanup a disbanded party (called after delay).
|
||||
"""
|
||||
def cleanup_party(party_id) do
|
||||
GenServer.cast(__MODULE__, {:cleanup_party, party_id})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:cleanup_party, party_id}, state) do
|
||||
case Map.get(state.parties, party_id) do
|
||||
%{disbanded: true} ->
|
||||
{:noreply, %{state | parties: Map.delete(state.parties, party_id)}}
|
||||
_ ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user