kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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},

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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)}")

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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

View 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

View File

@@ -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

View 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
View 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

View 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

View 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
View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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
View 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

View 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

View 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

View 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
View 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

View 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

View 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

View 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
View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

View File

@@ -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
View 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

View 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
View 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

View File

@@ -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

View File

@@ -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