port over some more

This commit is contained in:
ra
2026-02-14 23:58:01 -07:00
parent 0222be36c5
commit 61176cd416
107 changed files with 9124 additions and 375 deletions

View File

@@ -96,6 +96,7 @@ defmodule Odinsea.Channel.Client do
cp_item_move = Opcodes.cp_item_move()
cp_item_sort = Opcodes.cp_item_sort()
cp_item_gather = Opcodes.cp_item_gather()
cp_item_pickup = Opcodes.cp_item_pickup()
cp_use_item = Opcodes.cp_use_item()
cp_use_return_scroll = Opcodes.cp_use_return_scroll()
cp_use_scroll = Opcodes.cp_use_upgrade_scroll()
@@ -273,6 +274,12 @@ defmodule Odinsea.Channel.Client do
_ -> state
end
^cp_item_pickup ->
case Handler.Pickup.handle_item_pickup(packet, state) do
{:ok, new_state} -> new_state
_ -> state
end
^cp_use_item ->
case Handler.Inventory.handle_use_item(packet, state) do
{:ok, new_state} -> new_state

View File

@@ -0,0 +1,247 @@
defmodule Odinsea.Channel.Handler.Pickup do
@moduledoc """
Handles drop pickup from the map (item and meso drops).
Ported from src/handling/channel/handler/InventoryHandler.java
This handler processes CP_ItemPickup (0x10C) packets when a player
attempts to pick up a drop from the map.
"""
require Logger
alias Odinsea.Net.Packet.{In, Out}
alias Odinsea.Net.Opcodes
alias Odinsea.Game.{Character, Drop, Map}
alias Odinsea.Channel.Packets
@doc """
Handles item pickup from map (CP_ItemPickup).
Packet structure:
- tick (4 bytes): Client tick count
- oid (4 bytes): Object ID of the drop on the map
Ported from InventoryHandler.handlePickup()
"""
def handle_item_pickup(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)
{drop_oid, _packet} = In.decode_int(packet)
Logger.debug("Item pickup attempt: character=#{character.name}, drop_oid=#{drop_oid}")
# Attempt to pick up the drop
case attempt_pickup_drop(character, character_pid, drop_oid, client_state.channel_id) do
{:ok, :meso, amount} ->
Logger.debug("Picked up meso: #{amount}")
# Send pickup success response
send_pickup_result(client_state, 0, 0)
{:ok, client_state}
{:ok, :item, item} ->
Logger.debug("Picked up item: #{item.item_id}")
# Send pickup success response
send_pickup_result(client_state, 0, 0)
{:ok, client_state}
{:error, reason} ->
Logger.debug("Pickup failed: #{reason}")
# Send failure response (enable actions to unblock client)
send_enable_actions(client_state)
{:ok, client_state}
end
else
{:error, reason} ->
Logger.warning("Item pickup failed: #{inspect(reason)}")
send_enable_actions(client_state)
{:ok, client_state}
end
end
@doc """
Handles pet item pickup request (CP_PetDropPickUpRequest).
Similar to player pickup but initiated by pet movement.
"""
def handle_pet_item_pickup(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)
{pet_slot, packet} = In.decode_byte(packet)
{drop_oid, _packet} = In.decode_int(packet)
Logger.debug("Pet item pickup attempt: character=#{character.name}, pet_slot=#{pet_slot}, drop_oid=#{drop_oid}")
# Attempt to pick up the drop (pets have same rules but different animation)
case attempt_pickup_drop(character, character_pid, drop_oid, client_state.channel_id, pet_slot) do
{:ok, :meso, amount} ->
Logger.debug("Pet picked up meso: #{amount}")
{:ok, client_state}
{:ok, :item, item} ->
Logger.debug("Pet picked up item: #{item.item_id}")
{:ok, client_state}
{:error, _reason} ->
{:ok, client_state}
end
else
{:error, _reason} ->
{:ok, client_state}
end
end
# ============================================================================
# Private Helper Functions
# ============================================================================
defp attempt_pickup_drop(character, character_pid, drop_oid, channel_id, pet_slot \\ nil) do
# Call Map.pickup_drop to atomically attempt pickup
case Map.pickup_drop(character.map_id, channel_id, drop_oid, character.id) do
{:ok, drop} ->
# Successfully claimed the drop, now process it
process_pickup(character, character_pid, drop, channel_id, pet_slot)
{:error, reason} ->
{:error, reason}
end
end
defp process_pickup(character, character_pid, %Drop{} = drop, channel_id, pet_slot) do
cond do
Drop.meso?(drop) ->
process_meso_pickup(character, character_pid, drop, channel_id, pet_slot)
Drop.item?(drop) ->
process_item_pickup(character, character_pid, drop, channel_id, pet_slot)
true ->
{:error, :invalid_drop}
end
end
defp process_meso_pickup(character, character_pid, %Drop{meso: amount} = drop, channel_id, pet_slot) do
# Add meso to character
case Character.gain_meso(character_pid, amount, true) do
{:ok, _new_meso} ->
# Broadcast pickup animation to all players on map
broadcast_pickup(character.map_id, channel_id, drop.oid, character.id, pet_slot)
# Show meso gain in chat (optional)
# send_meso_gain_message(client_state, amount)
{:ok, :meso, amount}
{:error, reason} ->
Logger.warning("Failed to add meso: #{reason}")
{:error, :gain_meso_failed}
end
end
defp process_item_pickup(character, character_pid, %Drop{} = drop, channel_id, pet_slot) do
# Check inventory space
inventory_type = get_inventory_type(drop.item_id)
case Character.check_inventory_space(character_pid, inventory_type, drop.quantity) do
{:ok, _slot} ->
# Add item to inventory
item_to_add = drop.item || create_item_from_drop(drop)
case Character.add_item_from_drop(character_pid, item_to_add) do
{:ok, added_item} ->
# Broadcast pickup animation
broadcast_pickup(character.map_id, channel_id, drop.oid, character.id, pet_slot)
{:ok, :item, added_item}
{:error, reason} ->
Logger.warning("Failed to add item to inventory: #{reason}")
# Item couldn't be added - drop would normally be returned to map
# but for simplicity we just fail
{:error, :add_item_failed}
end
{:error, :inventory_full} ->
# Send inventory full message to client
{:error, :inventory_full}
{:error, reason} ->
{:error, reason}
end
end
defp create_item_from_drop(%Drop{} = drop) do
# Create a basic item struct from drop data
%{
item_id: drop.item_id,
quantity: drop.quantity,
position: 0 # Will be assigned by inventory
}
end
defp get_inventory_type(item_id) do
# Determine inventory type from item ID
type_prefix = div(item_id, 1_000_000)
case type_prefix do
1 -> :equip
2 -> :use
3 -> :setup
4 -> :etc
5 -> :cash
_ -> :etc
end
end
defp broadcast_pickup(map_id, channel_id, drop_oid, character_id, nil) do
# Player pickup - animation type 2
remove_packet = Packets.remove_drop(drop_oid, 2, character_id)
Map.broadcast(map_id, channel_id, remove_packet)
end
defp broadcast_pickup(map_id, channel_id, drop_oid, character_id, pet_slot) do
# Pet pickup - animation type 5
remove_packet = Packets.remove_drop(drop_oid, 5, character_id, pet_slot)
Map.broadcast(map_id, channel_id, remove_packet)
end
defp send_pickup_result(client_state, _result, _item_id) do
# Send inventory update or status packet
# For now, just enable actions
send_enable_actions(client_state)
end
defp send_enable_actions(client_state) do
# Send enable actions packet to allow further client actions
enable_packet = Packets.enable_actions()
send_packet(client_state, enable_packet)
end
defp send_packet(client_state, data) when is_pid(client_state) do
send(client_state, {:send_packet, data})
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 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
end

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,257 @@
defmodule Odinsea.Database.Redis do
@moduledoc """
Redis client for Odinsea.
Provides key-value storage, pub/sub, and caching functionality.
"""
require Logger
# ==================================================================================================
# Connection
# ==================================================================================================
defp conn do
# Get Redis connection from application environment
# In production, this would be a persistent connection pool
host = Application.get_env(:odinsea, :redis_host, "localhost")
port = Application.get_env(:odinsea, :redis_port, 6379)
database = Application.get_env(:odinsea, :redis_database, 0)
password = Application.get_env(:odinsea, :redis_password, nil)
opts = [host: host, port: port, database: database]
opts = if password, do: Keyword.put(opts, :password, password), else: opts
case Redix.start_link(opts) do
{:ok, conn} -> conn
{:error, _} -> nil
end
end
# ==================================================================================================
# Key-Value Operations
# ==================================================================================================
@doc """
Gets a value by key.
"""
@spec get(String.t()) :: {:ok, String.t() | nil} | {:error, term()}
def get(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, value} <- Redix.command(conn, ["GET", key]) do
{:ok, value}
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Sets a key to a value.
"""
@spec set(String.t(), String.t()) :: :ok | {:error, term()}
def set(key, value) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["SET", key, value]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Sets a key to a value with expiration (in seconds).
"""
@spec setex(String.t(), integer(), String.t()) :: :ok | {:error, term()}
def setex(key, seconds, value) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["SETEX", key, seconds, value]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Deletes a key.
"""
@spec del(String.t()) :: :ok | {:error, term()}
def del(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["DEL", key]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Checks if a key exists.
"""
@spec exists?(String.t()) :: boolean()
def exists?(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, count} <- Redix.command(conn, ["EXISTS", key]) do
count > 0
else
_ -> false
end
after
close_conn()
end
@doc """
Sets expiration on a key (in seconds).
"""
@spec expire(String.t(), integer()) :: :ok | {:error, term()}
def expire(key, seconds) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["EXPIRE", key, seconds]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
# ==================================================================================================
# Pub/Sub Operations
# ==================================================================================================
@doc """
Publishes a message to a channel.
The message is automatically JSON-encoded.
"""
@spec publish(String.t(), map()) :: :ok | {:error, term()}
def publish(channel, message) do
json_message = Jason.encode!(message)
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["PUBLISH", channel, json_message]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Subscribes to channels and handles messages with a callback function.
This is a blocking operation that should be run in a separate process.
"""
@spec subscribe([String.t()], (String.t(), map() -> any())) :: :ok | {:error, term()}
def subscribe(channels, callback) when is_list(channels) and is_function(callback, 2) do
# Pub/Sub in Redix requires a separate connection
host = Application.get_env(:odinsea, :redis_host, "localhost")
port = Application.get_env(:odinsea, :redis_port, 6379)
opts = [host: host, port: port]
case Redix.PubSub.start_link(opts) do
{:ok, pubsub} ->
# Subscribe to channels
Enum.each(channels, fn channel ->
Redix.PubSub.subscribe(pubsub, channel, self())
end)
# Message loop
message_loop(pubsub, callback)
{:error, reason} ->
{:error, reason}
end
end
defp message_loop(pubsub, callback) do
receive do
{:redix_pubsub, ^pubsub, :message, %{channel: channel, payload: payload}} ->
# Decode JSON payload
case Jason.decode(payload) do
{:ok, decoded} -> callback.(channel, decoded)
{:error, _} -> callback.(channel, %{"raw" => payload})
end
message_loop(pubsub, callback)
{:redix_pubsub, ^pubsub, :subscribed, %{channel: _channel}} ->
message_loop(pubsub, callback)
_ ->
message_loop(pubsub, callback)
end
end
# ==================================================================================================
# Helper Functions
# ==================================================================================================
defp close_conn do
# Note: In a production setup with connection pooling,
# this would return the connection to the pool instead of closing
:ok
end
@doc """
Gets the online count for a world.
"""
@spec get_world_online_count(integer()) :: integer()
def get_world_online_count(world_id) do
case get("world:#{world_id}:online_count") do
{:ok, nil} -> 0
{:ok, count} -> String.to_integer(count)
{:error, _} -> 0
end
end
@doc """
Updates the online count for a world.
"""
@spec update_world_online_count(integer(), integer()) :: :ok
def update_world_online_count(world_id, count) do
set("world:#{world_id}:online_count", to_string(count))
:ok
end
@doc """
Registers a player as online.
"""
@spec register_player_online(integer(), integer(), String.t()) :: :ok
def register_player_online(character_id, world_id, channel) do
setex("player:#{character_id}:online", 60, Jason.encode!(%{
world_id: world_id,
channel: channel,
timestamp: System.system_time(:second)
}))
:ok
end
@doc """
Unregisters a player as online.
"""
@spec unregister_player_online(integer()) :: :ok
def unregister_player_online(character_id) do
del("player:#{character_id}:online")
:ok
end
@doc """
Checks if a player is online.
"""
@spec player_online?(integer()) :: boolean()
def player_online?(character_id) do
exists?("player:#{character_id}:online")
end
end

View File

@@ -0,0 +1,25 @@
defmodule Odinsea.Database.Schema.Achievement do
@moduledoc """
Ecto schema for the achievements table.
Represents character achievements.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "achievements" do
field :achievementid, :integer, primary_key: true
field :charid, :integer, primary_key: true
field :accountid, :integer, default: 0
end
@doc """
Changeset for creating an achievement.
"""
def changeset(achievement, attrs) do
achievement
|> cast(attrs, [:achievementid, :charid, :accountid])
|> validate_required([:achievementid, :charid])
end
end

View File

@@ -0,0 +1,55 @@
defmodule Odinsea.Database.Schema.Alliance do
@moduledoc """
Ecto schema for the alliances table.
Represents guild alliances.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "alliances" do
field :name, :string
field :leaderid, :integer
field :guild1, :integer
field :guild2, :integer
field :guild3, :integer, default: 0
field :guild4, :integer, default: 0
field :guild5, :integer, default: 0
field :rank1, :string, default: "Master"
field :rank2, :string, default: "Jr.Master"
field :rank3, :string, default: "Member"
field :rank4, :string, default: "Member"
field :rank5, :string, default: "Member"
field :capacity, :integer, default: 2
field :notice, :string, default: ""
end
@doc """
Changeset for creating an alliance.
"""
def creation_changeset(alliance, attrs) do
alliance
|> cast(attrs, [:name, :leaderid, :guild1, :guild2, :capacity])
|> validate_required([:name, :leaderid, :guild1, :guild2])
|> validate_length(:name, min: 1, max: 13)
|> unique_constraint(:name)
end
@doc """
Changeset for updating alliance guilds.
"""
def guilds_changeset(alliance, attrs) do
alliance
|> cast(attrs, [:guild1, :guild2, :guild3, :guild4, :guild5])
end
@doc """
Changeset for updating alliance ranks.
"""
def ranks_changeset(alliance, attrs) do
alliance
|> cast(attrs, [:rank1, :rank2, :rank3, :rank4, :rank5])
end
end

View File

@@ -0,0 +1,26 @@
defmodule Odinsea.Database.Schema.Android do
@moduledoc """
Ecto schema for the androids table.
Represents android companion data.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:uniqueid, :id, autogenerate: true}
schema "androids" do
field :name, :string, default: "Android"
field :hair, :integer, default: 0
field :face, :integer, default: 0
end
@doc """
Changeset for creating/updating an android.
"""
def changeset(android, attrs) do
android
|> cast(attrs, [:name, :hair, :face])
|> validate_required([:name])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.BattleLog do
@moduledoc """
Ecto schema for the battlelog table.
Represents PvP battle records between accounts.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:battlelogid, :id, autogenerate: true}
@timestamps_opts [inserted_at: :when, updated_at: false]
schema "battlelog" do
field :accid, :integer, default: 0
field :accid_to, :integer, default: 0
field :when, :naive_datetime
end
@doc """
Changeset for creating a battle log entry.
"""
def changeset(battle_log, attrs) do
battle_log
|> cast(attrs, [:accid, :accid_to])
|> validate_required([:accid, :accid_to])
end
end

View File

@@ -0,0 +1,33 @@
defmodule Odinsea.Database.Schema.BbsReply do
@moduledoc """
Ecto schema for the bbs_replies table.
Represents guild BBS thread replies.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:replyid, :id, autogenerate: true}
schema "bbs_replies" do
field :threadid, :integer
field :postercid, :integer
field :timestamp, :integer
field :content, :string, default: ""
field :guildid, :integer, default: 0
belongs_to :bbs_thread, Odinsea.Database.Schema.BbsThread,
foreign_key: :threadid,
references: :threadid,
define_field: false
end
@doc """
Changeset for creating a BBS reply.
"""
def changeset(bbs_reply, attrs) do
bbs_reply
|> cast(attrs, [:threadid, :postercid, :timestamp, :content, :guildid])
|> validate_required([:threadid, :postercid, :timestamp])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.BbsThread do
@moduledoc """
Ecto schema for the bbs_threads table.
Represents guild BBS threads.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:threadid, :id, autogenerate: true}
schema "bbs_threads" do
field :postercid, :integer
field :name, :string, default: ""
field :timestamp, :integer
field :icon, :integer
field :startpost, :string
field :guildid, :integer
field :localthreadid, :integer
has_many :bbs_replies, Odinsea.Database.Schema.BbsReply, foreign_key: :threadid
end
@doc """
Changeset for creating a BBS thread.
"""
def creation_changeset(bbs_thread, attrs) do
bbs_thread
|> cast(attrs, [:postercid, :name, :timestamp, :icon, :startpost, :guildid, :localthreadid])
|> validate_required([:postercid, :timestamp, :guildid, :localthreadid])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.Buddy do
@moduledoc """
Ecto schema for the buddies table.
Represents buddy list entries for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "buddies" do
field :characterid, :integer
field :buddyid, :integer
field :pending, :integer, default: 0
field :groupname, :string, default: "ETC"
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating a buddy entry.
"""
def changeset(buddy, attrs) do
buddy
|> cast(attrs, [:characterid, :buddyid, :pending, :groupname])
|> validate_required([:characterid, :buddyid])
end
end

View File

@@ -0,0 +1,24 @@
defmodule Odinsea.Database.Schema.CashshopLimitSell do
@moduledoc """
Ecto schema for the cashshop_limit_sell table.
Represents limited sale quantities for cash shop items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:serial, :integer, autogenerate: false}
schema "cashshop_limit_sell" do
field :amount, :integer, default: 0
end
@doc """
Changeset for cashshop limit sell.
"""
def changeset(cashshop_limit_sell, attrs) do
cashshop_limit_sell
|> cast(attrs, [:serial, :amount])
|> validate_required([:serial])
end
end

View File

@@ -0,0 +1,40 @@
defmodule Odinsea.Database.Schema.CashshopModifiedItem do
@moduledoc """
Ecto schema for the cashshop_modified_items table.
Represents modified cash shop items (discounts, etc).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:serial, :integer, autogenerate: false}
schema "cashshop_modified_items" do
field :discount_price, :integer, default: -1
field :mark, :integer, default: -1
field :showup, :integer, default: 0
field :itemid, :integer, default: 0
field :priority, :integer, default: 0
field :package, :integer, default: 0
field :period, :integer, default: 0
field :gender, :integer, default: 0
field :count, :integer, default: 0
field :meso, :integer, default: 0
field :unk_1, :integer, default: 0
field :unk_2, :integer, default: 0
field :unk_3, :integer, default: 0
field :extra_flags, :integer, default: 0
end
@doc """
Changeset for cashshop modified items.
"""
def changeset(cashshop_modified_item, attrs) do
cashshop_modified_item
|> cast(attrs, [
:serial, :discount_price, :mark, :showup, :itemid, :priority,
:package, :period, :gender, :count, :meso, :unk_1, :unk_2, :unk_3, :extra_flags
])
|> validate_required([:serial])
end
end

View File

@@ -0,0 +1,26 @@
defmodule Odinsea.Database.Schema.CharacterSlot do
@moduledoc """
Ecto schema for the character_slots table.
Represents character slot counts per world.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "character_slots" do
field :accid, :integer, default: 0
field :worldid, :integer, default: 0
field :charslots, :integer, default: 6
end
@doc """
Changeset for creating/updating character slots.
"""
def changeset(character_slot, attrs) do
character_slot
|> cast(attrs, [:accid, :worldid, :charslots])
|> validate_required([:accid, :worldid])
end
end

View File

@@ -0,0 +1,29 @@
defmodule Odinsea.Database.Schema.CheatLog do
@moduledoc """
Ecto schema for the cheatlog table.
Represents cheat/anti-cheat log entries.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
@timestamps_opts [inserted_at: :lastoffensetime, updated_at: false]
schema "cheatlog" do
field :characterid, :integer, default: 0
field :offense, :string
field :count, :integer, default: 0
field :lastoffensetime, :naive_datetime
field :param, :string
end
@doc """
Changeset for creating a cheat log entry.
"""
def changeset(cheat_log, attrs) do
cheat_log
|> cast(attrs, [:characterid, :offense, :count, :param])
|> validate_required([:characterid, :offense])
end
end

View File

@@ -0,0 +1,26 @@
defmodule Odinsea.Database.Schema.CompensationLog do
@moduledoc """
Ecto schema for the compensationlog_confirmed table.
Represents compensation records for players.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:chrname, :string, autogenerate: false}
schema "compensationlog_confirmed" do
field :donor, :integer, default: 0
field :value, :integer, default: 0
field :taken, :integer, default: 0
end
@doc """
Changeset for compensation log.
"""
def changeset(compensation_log, attrs) do
compensation_log
|> cast(attrs, [:chrname, :donor, :value, :taken])
|> validate_required([:chrname])
end
end

View File

@@ -0,0 +1,41 @@
defmodule Odinsea.Database.Schema.CsItem do
@moduledoc """
Ecto schema for the csitems table.
Represents cash shop inventory items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventoryitemid, :integer, autogenerate: false}
schema "csitems" do
field :characterid, :integer
field :accountid, :integer
field :packageid, :integer
field :itemid, :integer, default: 0
field :inventorytype, :integer, default: 0
field :position, :integer, default: 0
field :quantity, :integer, default: 0
field :owner, :string
field :gm_log, :string, source: :GM_Log
field :uniqueid, :integer, default: -1
field :flag, :integer, default: 0
field :expiredate, :integer, default: -1
field :type, :integer, default: 0
field :sender, :string, default: ""
end
@doc """
Changeset for creating/updating a cash shop item.
"""
def changeset(cs_item, attrs) do
cs_item
|> cast(attrs, [
:inventoryitemid, :characterid, :accountid, :packageid, :itemid,
:inventorytype, :position, :quantity, :owner, :gm_log, :uniqueid,
:flag, :expiredate, :type, :sender
])
|> validate_required([:inventoryitemid, :itemid, :inventorytype, :position, :quantity])
end
end

View File

@@ -0,0 +1,29 @@
defmodule Odinsea.Database.Schema.Donation do
@moduledoc """
Ecto schema for the donation table.
Represents donation records.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
@timestamps_opts [inserted_at: :date, updated_at: false]
schema "donation" do
field :date, :naive_datetime
field :ip, :string
field :username, :string
field :quantity, :integer
field :status, :integer, default: 0
end
@doc """
Changeset for donation records.
"""
def changeset(donation, attrs) do
donation
|> cast(attrs, [:ip, :username, :quantity, :status])
|> validate_required([:ip, :username])
end
end

View File

@@ -0,0 +1,34 @@
defmodule Odinsea.Database.Schema.DonorLog do
@moduledoc """
Ecto schema for the donorlog table.
Represents donation transaction logs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "donorlog" do
field :accname, :string, default: ""
field :acc_id, :integer, default: 0, source: :accId
field :chrname, :string, default: ""
field :chr_id, :integer, default: 0, source: :chrId
field :log, :string, default: ""
field :time, :string, default: ""
field :previous_points, :integer, default: 0, source: :previousPoints
field :current_points, :integer, default: 0, source: :currentPoints
end
@doc """
Changeset for creating a donor log entry.
"""
def changeset(donor_log, attrs) do
donor_log
|> cast(attrs, [
:accname, :acc_id, :chrname, :chr_id, :log, :time,
:previous_points, :current_points
])
|> validate_required([:acc_id])
end
end

View File

@@ -0,0 +1,29 @@
defmodule Odinsea.Database.Schema.DropData do
@moduledoc """
Ecto schema for the drop_data table.
Represents monster drop tables.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "drop_data" do
field :dropperid, :integer
field :itemid, :integer, default: 0
field :minimum_quantity, :integer, default: 1
field :maximum_quantity, :integer, default: 1
field :questid, :integer, default: 0
field :chance, :integer, default: 0
end
@doc """
Changeset for creating/updating drop data.
"""
def changeset(drop_data, attrs) do
drop_data
|> cast(attrs, [:dropperid, :itemid, :minimum_quantity, :maximum_quantity, :questid, :chance])
|> validate_required([:dropperid, :itemid, :chance])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.DropDataGlobal do
@moduledoc """
Ecto schema for the drop_data_global table.
Represents global drops that apply to all monsters in a continent.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "drop_data_global" do
field :continent, :integer
field :drop_type, :integer, default: 0, source: :dropType
field :itemid, :integer, default: 0
field :minimum_quantity, :integer, default: 1
field :maximum_quantity, :integer, default: 1
field :questid, :integer, default: 0
field :chance, :integer, default: 0
field :comments, :string
end
@doc """
Changeset for creating/updating global drop data.
"""
def changeset(drop_data_global, attrs) do
drop_data_global
|> cast(attrs, [:continent, :drop_type, :itemid, :minimum_quantity, :maximum_quantity, :questid, :chance, :comments])
|> validate_required([:continent, :itemid, :chance])
end
end

View File

@@ -0,0 +1,41 @@
defmodule Odinsea.Database.Schema.DueyItem do
@moduledoc """
Ecto schema for the dueyitems table.
Represents Duey package items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventoryitemid, :integer, autogenerate: false}
schema "dueyitems" do
field :characterid, :integer
field :accountid, :integer
field :packageid, :integer
field :itemid, :integer, default: 0
field :inventorytype, :integer, default: 0
field :position, :integer, default: 0
field :quantity, :integer, default: 0
field :owner, :string
field :gm_log, :string, source: :GM_Log
field :uniqueid, :integer, default: -1
field :flag, :integer, default: 0
field :expiredate, :integer, default: -1
field :type, :integer, default: 0
field :sender, :string, default: ""
end
@doc """
Changeset for creating/updating a duey item.
"""
def changeset(duey_item, attrs) do
duey_item
|> cast(attrs, [
:inventoryitemid, :characterid, :accountid, :packageid, :itemid,
:inventorytype, :position, :quantity, :owner, :gm_log, :uniqueid,
:flag, :expiredate, :type, :sender
])
|> validate_required([:inventoryitemid, :itemid, :inventorytype, :position, :quantity])
end
end

View File

@@ -0,0 +1,37 @@
defmodule Odinsea.Database.Schema.DueyPackage do
@moduledoc """
Ecto schema for the dueypackages table.
Represents Duey packages (mail/delivery system).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:package_id, :id, autogenerate: true, source: :PackageId}
schema "dueypackages" do
field :reciever_id, :integer, source: :RecieverId
field :sender_name, :string, source: :SenderName
field :mesos, :integer, default: 0, source: :Mesos
field :timestamp, :integer, source: :TimeStamp
field :checked, :integer, default: 1, source: :Checked
field :type, :integer, source: :Type
end
@doc """
Changeset for creating a duey package.
"""
def creation_changeset(duey_package, attrs) do
duey_package
|> cast(attrs, [:reciever_id, :sender_name, :mesos, :timestamp, :type])
|> validate_required([:reciever_id, :sender_name, :type])
end
@doc """
Changeset for marking package as checked.
"""
def checked_changeset(duey_package, attrs) do
duey_package
|> cast(attrs, [:checked])
end
end

View File

@@ -0,0 +1,25 @@
defmodule Odinsea.Database.Schema.ExtendedSlot do
@moduledoc """
Ecto schema for the extendedslots table.
Represents extended inventory slots for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "extendedslots" do
field :characterid, :integer, default: 0
field :item_id, :integer, default: 0, source: :itemId
end
@doc """
Changeset for creating an extended slot entry.
"""
def changeset(extended_slot, attrs) do
extended_slot
|> cast(attrs, [:characterid, :item_id])
|> validate_required([:characterid])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.FameLog do
@moduledoc """
Ecto schema for the famelog table.
Represents fame (reputation) transactions between characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:famelogid, :id, autogenerate: true}
@timestamps_opts [inserted_at: :when, updated_at: false]
schema "famelog" do
field :characterid, :integer, default: 0
field :characterid_to, :integer, default: 0
field :when, :naive_datetime
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating a fame log entry.
"""
def changeset(fame_log, attrs) do
fame_log
|> cast(attrs, [:characterid, :characterid_to])
|> validate_required([:characterid, :characterid_to])
end
end

View File

@@ -0,0 +1,34 @@
defmodule Odinsea.Database.Schema.Familiar do
@moduledoc """
Ecto schema for the familiars table.
Represents familiar data for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "familiars" do
field :characterid, :integer, default: 0
field :familiar, :integer, default: 0
field :name, :string, default: ""
field :fatigue, :integer, default: 0
field :expiry, :integer, default: 0
field :vitality, :integer, default: 0
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a familiar.
"""
def changeset(familiar, attrs) do
familiar
|> cast(attrs, [:characterid, :familiar, :name, :fatigue, :expiry, :vitality])
|> validate_required([:characterid, :familiar])
end
end

View File

@@ -0,0 +1,33 @@
defmodule Odinsea.Database.Schema.Family do
@moduledoc """
Ecto schema for the families table.
Represents family data in the game.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:familyid, :id, autogenerate: true}
schema "families" do
field :leaderid, :integer, default: 0
field :notice, :string, default: ""
end
@doc """
Changeset for creating a family.
"""
def creation_changeset(family, attrs) do
family
|> cast(attrs, [:leaderid, :notice])
|> validate_required([:leaderid])
end
@doc """
Changeset for updating family notice.
"""
def notice_changeset(family, attrs) do
family
|> cast(attrs, [:notice])
end
end

View File

@@ -0,0 +1,28 @@
defmodule Odinsea.Database.Schema.Gift do
@moduledoc """
Ecto schema for the gifts table.
Represents cash shop gifts sent between characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:giftid, :id, autogenerate: true}
schema "gifts" do
field :recipient, :integer, default: 0
field :from, :string, default: ""
field :message, :string, default: ""
field :sn, :integer, default: 0
field :uniqueid, :integer, default: 0
end
@doc """
Changeset for creating a gift.
"""
def changeset(gift, attrs) do
gift
|> cast(attrs, [:recipient, :from, :message, :sn, :uniqueid])
|> validate_required([:recipient, :from])
end
end

View File

@@ -0,0 +1,28 @@
defmodule Odinsea.Database.Schema.GmLog do
@moduledoc """
Ecto schema for the gmlog table.
Represents GM command usage logs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:gmlogid, :id, autogenerate: true}
@timestamps_opts [inserted_at: :time, updated_at: false]
schema "gmlog" do
field :cid, :integer, default: 0
field :command, :string
field :mapid, :integer, default: 0
field :time, :naive_datetime
end
@doc """
Changeset for creating a GM log entry.
"""
def changeset(gm_log, attrs) do
gm_log
|> cast(attrs, [:cid, :command, :mapid])
|> validate_required([:cid, :command])
end
end

View File

@@ -0,0 +1,63 @@
defmodule Odinsea.Database.Schema.Guild do
@moduledoc """
Ecto schema for the guilds table.
Represents guild data in the game.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:guildid, :id, autogenerate: true}
schema "guilds" do
field :leader, :integer, default: 0
field :gp, :integer, default: 0, source: :GP
field :logo, :integer
field :logo_color, :integer, default: 0, source: :logoColor
field :name, :string
field :rank1title, :string, default: "Master"
field :rank2title, :string, default: "Jr. Master"
field :rank3title, :string, default: "Member"
field :rank4title, :string, default: "Member"
field :rank5title, :string, default: "Member"
field :capacity, :integer, default: 10
field :logo_bg, :integer, source: :logoBG
field :logo_bg_color, :integer, default: 0, source: :logoBGColor
field :notice, :string
field :signature, :integer, default: 0
field :alliance, :integer, default: 0
has_many :guild_skills, Odinsea.Database.Schema.GuildSkill, foreign_key: :guildid
end
@doc """
Changeset for creating a guild.
"""
def creation_changeset(guild, attrs) do
guild
|> cast(attrs, [:leader, :name, :capacity, :logo, :logo_color, :logo_bg, :logo_bg_color])
|> validate_required([:leader, :name])
|> validate_length(:name, min: 1, max: 45)
|> unique_constraint(:name)
end
@doc """
Changeset for updating guild settings.
"""
def settings_changeset(guild, attrs) do
guild
|> cast(attrs, [
:rank1title, :rank2title, :rank3title, :rank4title, :rank5title,
:capacity, :notice, :signature, :alliance
])
end
@doc """
Changeset for updating guild leader.
"""
def leader_changeset(guild, attrs) do
guild
|> cast(attrs, [:leader])
|> validate_required([:leader])
end
end

View File

@@ -0,0 +1,33 @@
defmodule Odinsea.Database.Schema.GuildSkill do
@moduledoc """
Ecto schema for the guildskills table.
Represents purchased guild skills.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "guildskills" do
field :guildid, :integer, default: 0
field :skillid, :integer, default: 0
field :level, :integer, default: 1
field :timestamp, :integer, default: 0
field :purchaser, :string, default: ""
belongs_to :guild, Odinsea.Database.Schema.Guild,
foreign_key: :guildid,
references: :guildid,
define_field: false
end
@doc """
Changeset for creating/updating a guild skill.
"""
def changeset(guild_skill, attrs) do
guild_skill
|> cast(attrs, [:guildid, :skillid, :level, :timestamp, :purchaser])
|> validate_required([:guildid, :skillid])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.HiredMerch do
@moduledoc """
Ecto schema for the hiredmerch table.
Represents hired merchant storage.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:package_id, :id, autogenerate: true, source: :PackageId}
schema "hiredmerch" do
field :characterid, :integer, default: 0
field :accountid, :integer
field :mesos, :integer, default: 0, source: :Mesos
field :time, :integer, source: :time
end
@doc """
Changeset for creating/updating hired merchant data.
"""
def changeset(hired_merch, attrs) do
hired_merch
|> cast(attrs, [:characterid, :accountid, :mesos, :time])
|> validate_required([:characterid])
end
end

View File

@@ -0,0 +1,41 @@
defmodule Odinsea.Database.Schema.HiredMerchItem do
@moduledoc """
Ecto schema for the hiredmerchitems table.
Represents hired merchant items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventoryitemid, :integer, autogenerate: false}
schema "hiredmerchitems" do
field :characterid, :integer
field :accountid, :integer
field :packageid, :integer
field :itemid, :integer, default: 0
field :inventorytype, :integer, default: 0
field :position, :integer, default: 0
field :quantity, :integer, default: 0
field :owner, :string
field :gm_log, :string, source: :GM_Log
field :uniqueid, :integer, default: -1
field :flag, :integer, default: 0
field :expiredate, :integer, default: -1
field :type, :integer, default: 0
field :sender, :string, default: ""
end
@doc """
Changeset for creating/updating a hired merchant item.
"""
def changeset(hired_merch_item, attrs) do
hired_merch_item
|> cast(attrs, [
:inventoryitemid, :characterid, :accountid, :packageid, :itemid,
:inventorytype, :position, :quantity, :owner, :gm_log, :uniqueid,
:flag, :expiredate, :type, :sender
])
|> validate_required([:inventoryitemid, :itemid, :inventorytype, :position, :quantity])
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.HyperrockLocation do
@moduledoc """
Ecto schema for the hyperrocklocations table.
Represents hyper teleport rock locations for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:trockid, :id, autogenerate: true}
schema "hyperrocklocations" do
field :characterid, :integer
field :mapid, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a hyper teleport rock location.
"""
def changeset(hyperrock_location, attrs) do
hyperrock_location
|> cast(attrs, [:characterid, :mapid])
|> validate_required([:characterid, :mapid])
end
end

View File

@@ -0,0 +1,34 @@
defmodule Odinsea.Database.Schema.Imp do
@moduledoc """
Ecto schema for the imps table.
Represents Imp (pocket pet) data for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:impid, :id, autogenerate: true}
schema "imps" do
field :characterid, :integer, default: 0
field :itemid, :integer, default: 0
field :level, :integer, default: 1
field :state, :integer, default: 1
field :closeness, :integer, default: 0
field :fullness, :integer, default: 0
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating an imp.
"""
def changeset(imp, attrs) do
imp
|> cast(attrs, [:characterid, :itemid, :level, :state, :closeness, :fullness])
|> validate_required([:characterid, :itemid])
end
end

View File

@@ -0,0 +1,58 @@
defmodule Odinsea.Database.Schema.InventoryEquipment do
@moduledoc """
Ecto schema for the inventoryequipment table.
Represents equipment stats for inventory items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventoryequipmentid, :id, autogenerate: true}
schema "inventoryequipment" do
field :inventoryitemid, :integer, default: 0
field :upgradeslots, :integer, default: 0
field :level, :integer, default: 0
field :str, :integer, default: 0
field :dex, :integer, default: 0
field :int, :integer, default: 0
field :luk, :integer, default: 0
field :hp, :integer, default: 0
field :mp, :integer, default: 0
field :watk, :integer, default: 0
field :matk, :integer, default: 0
field :wdef, :integer, default: 0
field :mdef, :integer, default: 0
field :acc, :integer, default: 0
field :avoid, :integer, default: 0
field :hands, :integer, default: 0
field :speed, :integer, default: 0
field :jump, :integer, default: 0
field :vicioushammer, :integer, default: 0, source: :ViciousHammer
field :itemexp, :integer, default: 0, source: :itemEXP
field :durability, :integer, default: -1
field :enhance, :integer, default: 0
field :potential1, :integer, default: 0
field :potential2, :integer, default: 0
field :potential3, :integer, default: 0
field :hp_r, :integer, default: 0, source: :hpR
field :mp_r, :integer, default: 0, source: :mpR
field :incskill, :integer, default: -1, source: :incSkill
field :charmexp, :integer, default: -1, source: :charmEXP
field :pvpdamage, :integer, default: 0, source: :pvpDamage
end
@doc """
Changeset for creating/updating inventory equipment.
"""
def changeset(inventory_equipment, attrs) do
inventory_equipment
|> cast(attrs, [
:inventoryitemid, :upgradeslots, :level, :str, :dex, :int, :luk,
:hp, :mp, :watk, :matk, :wdef, :mdef, :acc, :avoid, :hands, :speed, :jump,
:vicioushammer, :itemexp, :durability, :enhance, :potential1, :potential2,
:potential3, :hp_r, :mp_r, :incskill, :charmexp, :pvpdamage
])
|> validate_required([:inventoryitemid])
end
end

View File

@@ -0,0 +1,25 @@
defmodule Odinsea.Database.Schema.InventoryLog do
@moduledoc """
Ecto schema for the inventorylog table.
Represents inventory transaction logs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventorylogid, :id, autogenerate: true}
schema "inventorylog" do
field :inventoryitemid, :integer, default: 0
field :msg, :string
end
@doc """
Changeset for creating an inventory log entry.
"""
def changeset(inventory_log, attrs) do
inventory_log
|> cast(attrs, [:inventoryitemid, :msg])
|> validate_required([:inventoryitemid, :msg])
end
end

View File

@@ -0,0 +1,35 @@
defmodule Odinsea.Database.Schema.InventorySlot do
@moduledoc """
Ecto schema for the inventoryslot table.
Represents inventory slot counts for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "inventoryslot" do
field :characterid, :integer
field :equip, :integer
field :use, :integer
field :setup, :integer
field :etc, :integer
field :cash, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating inventory slots.
"""
def changeset(inventory_slot, attrs) do
inventory_slot
|> cast(attrs, [:characterid, :equip, :use, :setup, :etc, :cash])
|> validate_required([:characterid])
|> unique_constraint(:characterid)
end
end

View File

@@ -0,0 +1,24 @@
defmodule Odinsea.Database.Schema.IpBan do
@moduledoc """
Ecto schema for the ipbans table.
Represents IP address bans.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:ipbanid, :id, autogenerate: true}
schema "ipbans" do
field :ip, :string, default: ""
end
@doc """
Changeset for creating an IP ban.
"""
def changeset(ip_ban, attrs) do
ip_ban
|> cast(attrs, [:ip])
|> validate_required([:ip])
end
end

View File

@@ -0,0 +1,26 @@
defmodule Odinsea.Database.Schema.IpLog do
@moduledoc """
Ecto schema for the iplog table.
Represents IP address login logs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "iplog" do
field :accid, :integer
field :ip, :string
field :time, :string
end
@doc """
Changeset for creating an IP log entry.
"""
def changeset(ip_log, attrs) do
ip_log
|> cast(attrs, [:accid, :ip, :time])
|> validate_required([:accid, :ip])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.IpvoteLog do
@moduledoc """
Ecto schema for the ipvotelog table.
Represents IP-based voting records.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:vid, :id, autogenerate: true}
schema "ipvotelog" do
field :accid, :integer, default: 0
field :ipaddress, :string, default: "127.0.0.1"
field :votetime, :integer, default: 0
field :votetype, :integer, default: 0
end
@doc """
Changeset for IP vote log.
"""
def changeset(ipvote_log, attrs) do
ipvote_log
|> cast(attrs, [:accid, :ipaddress, :votetime, :votetype])
|> validate_required([:accid])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.Keymap do
@moduledoc """
Ecto schema for the keymap table.
Represents key bindings for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "keymap" do
field :characterid, :integer, default: 0
field :key, :integer, default: 0
field :type, :integer, default: 0
field :action, :integer, default: 0
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a keymap entry.
"""
def changeset(keymap, attrs) do
keymap
|> cast(attrs, [:characterid, :key, :type, :action])
|> validate_required([:characterid, :key, :type, :action])
end
end

View File

@@ -0,0 +1,25 @@
defmodule Odinsea.Database.Schema.MacBan do
@moduledoc """
Ecto schema for the macbans table.
Represents MAC address bans.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:macbanid, :id, autogenerate: true}
schema "macbans" do
field :mac, :string
end
@doc """
Changeset for creating a MAC ban.
"""
def changeset(mac_ban, attrs) do
mac_ban
|> cast(attrs, [:mac])
|> validate_required([:mac])
|> unique_constraint(:mac)
end
end

View File

@@ -0,0 +1,24 @@
defmodule Odinsea.Database.Schema.MacFilter do
@moduledoc """
Ecto schema for the macfilters table.
Represents MAC address filters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:macfilterid, :id, autogenerate: true}
schema "macfilters" do
field :filter, :string
end
@doc """
Changeset for creating a MAC filter.
"""
def changeset(mac_filter, attrs) do
mac_filter
|> cast(attrs, [:filter])
|> validate_required([:filter])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.Monsterbook do
@moduledoc """
Ecto schema for the monsterbook table.
Represents monster book cards collected by characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "monsterbook" do
field :charid, :integer, default: 0
field :cardid, :integer, default: 0
field :level, :integer, default: 1
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :charid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a monsterbook entry.
"""
def changeset(monsterbook, attrs) do
monsterbook
|> cast(attrs, [:charid, :cardid, :level])
|> validate_required([:charid, :cardid])
end
end

View File

@@ -0,0 +1,33 @@
defmodule Odinsea.Database.Schema.MountData do
@moduledoc """
Ecto schema for the mountdata table.
Represents mount levels and experience for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "mountdata" do
field :characterid, :integer
field :level, :integer, default: 0, source: :Level
field :exp, :integer, default: 0, source: :Exp
field :fatigue, :integer, default: 0, source: :Fatigue
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating mount data.
"""
def changeset(mount_data, attrs) do
mount_data
|> cast(attrs, [:characterid, :level, :exp, :fatigue])
|> validate_required([:characterid])
|> unique_constraint(:characterid)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.MtsCart do
@moduledoc """
Ecto schema for the mts_cart table.
Represents MTS cart items for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "mts_cart" do
field :characterid, :integer, default: 0
field :itemid, :integer, default: 0
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating an MTS cart entry.
"""
def changeset(mts_cart, attrs) do
mts_cart
|> cast(attrs, [:characterid, :itemid])
|> validate_required([:characterid, :itemid])
end
end

View File

@@ -0,0 +1,28 @@
defmodule Odinsea.Database.Schema.MtsItem do
@moduledoc """
Ecto schema for the mts_items table.
Represents MTS (Maple Trading System) listings.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :integer, autogenerate: false}
schema "mts_items" do
field :tab, :integer, default: 1
field :price, :integer, default: 0
field :characterid, :integer, default: 0
field :seller, :string, default: ""
field :expiration, :integer, default: 0
end
@doc """
Changeset for creating an MTS item listing.
"""
def changeset(mts_item, attrs) do
mts_item
|> cast(attrs, [:id, :tab, :price, :characterid, :seller, :expiration])
|> validate_required([:id, :characterid])
end
end

View File

@@ -0,0 +1,41 @@
defmodule Odinsea.Database.Schema.MtsTransfer do
@moduledoc """
Ecto schema for the mtstransfer table.
Represents MTS transfer items.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:inventoryitemid, :integer, autogenerate: false}
schema "mtstransfer" do
field :characterid, :integer
field :accountid, :integer
field :packageid, :integer
field :itemid, :integer, default: 0
field :inventorytype, :integer, default: 0
field :position, :integer, default: 0
field :quantity, :integer, default: 0
field :owner, :string
field :gm_log, :string, source: :GM_Log
field :uniqueid, :integer, default: -1
field :flag, :integer, default: 0
field :expiredate, :integer, default: -1
field :type, :integer, default: 0
field :sender, :string, default: ""
end
@doc """
Changeset for MTS transfer items.
"""
def changeset(mts_transfer, attrs) do
mts_transfer
|> cast(attrs, [
:inventoryitemid, :characterid, :accountid, :packageid, :itemid,
:inventorytype, :position, :quantity, :owner, :gm_log, :uniqueid,
:flag, :expiredate, :type, :sender
])
|> validate_required([:inventoryitemid, :itemid])
end
end

View File

@@ -0,0 +1,28 @@
defmodule Odinsea.Database.Schema.Note do
@moduledoc """
Ecto schema for the notes table.
Represents in-game notes/messages between characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "notes" do
field :to, :string, default: ""
field :from, :string, default: ""
field :message, :string
field :timestamp, :integer
field :gift, :integer, default: 0
end
@doc """
Changeset for creating a note.
"""
def changeset(note, attrs) do
note
|> cast(attrs, [:to, :from, :message, :timestamp, :gift])
|> validate_required([:to, :from, :message, :timestamp])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.NxCode do
@moduledoc """
Ecto schema for the nxcode table.
Represents NX (cash) redemption codes.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:code, :string, autogenerate: false}
schema "nxcode" do
field :valid, :integer, default: 1
field :user, :string
field :type, :integer, default: 0
field :item, :integer, default: 10000
end
@doc """
Changeset for creating/updating an NX code.
"""
def changeset(nx_code, attrs) do
nx_code
|> cast(attrs, [:code, :valid, :user, :type, :item])
|> validate_required([:code])
end
end

View File

@@ -0,0 +1,38 @@
defmodule Odinsea.Database.Schema.Pet do
@moduledoc """
Ecto schema for the pets table.
Represents pet data in the game.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:petid, :id, autogenerate: true}
schema "pets" do
field :name, :string
field :level, :integer, default: 1
field :closeness, :integer, default: 0
field :fullness, :integer, default: 0
field :seconds, :integer, default: 0
field :flags, :integer, default: 0
end
@doc """
Changeset for creating a pet.
"""
def creation_changeset(pet, attrs) do
pet
|> cast(attrs, [:name, :level, :closeness, :fullness])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 13)
end
@doc """
Changeset for updating pet stats.
"""
def stats_changeset(pet, attrs) do
pet
|> cast(attrs, [:level, :closeness, :fullness, :seconds, :flags])
end
end

View File

@@ -0,0 +1,41 @@
defmodule Odinsea.Database.Schema.Playernpc do
@moduledoc """
Ecto schema for the playernpcs table.
Represents player-created NPCs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "playernpcs" do
field :name, :string
field :hair, :integer
field :face, :integer
field :skin, :integer
field :x, :integer, default: 0
field :y, :integer, default: 0
field :map, :integer
field :charid, :integer
field :scriptid, :integer
field :foothold, :integer
field :dir, :integer, default: 0
field :gender, :integer, default: 0
field :pets, :string, default: "0,0,0"
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :charid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a player NPC.
"""
def changeset(playernpc, attrs) do
playernpc
|> cast(attrs, [:name, :hair, :face, :skin, :x, :y, :map, :charid, :scriptid, :foothold, :dir, :gender, :pets])
|> validate_required([:name, :map, :charid, :scriptid])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.PlayernpcEquip do
@moduledoc """
Ecto schema for the playernpcs_equip table.
Represents equipment for player NPCs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "playernpcs_equip" do
field :npcid, :integer
field :equipid, :integer
field :equippos, :integer
field :charid, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :charid,
references: :id,
define_field: false
end
@doc """
Changeset for player NPC equipment.
"""
def changeset(playernpc_equip, attrs) do
playernpc_equip
|> cast(attrs, [:npcid, :equipid, :equippos, :charid])
|> validate_required([:npcid, :equipid, :charid])
end
end

View File

@@ -0,0 +1,51 @@
defmodule Odinsea.Database.Schema.Pokemon do
@moduledoc """
Ecto schema for the pokemon table.
Represents Pokemon-like pet data for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "pokemon" do
field :monsterid, :integer, default: 0
field :characterid, :integer, default: 0
field :level, :integer, default: 1
field :exp, :integer, default: 0
field :name, :string, default: ""
field :nature, :integer, default: 0
field :active, :integer, default: 0
field :accountid, :integer, default: 0
field :itemid, :integer, default: 0
field :gender, :integer, default: -1
field :hpiv, :integer, default: -1
field :atkiv, :integer, default: -1
field :defiv, :integer, default: -1
field :spatkiv, :integer, default: -1
field :spdefiv, :integer, default: -1
field :speediv, :integer, default: -1
field :evaiv, :integer, default: -1
field :acciv, :integer, default: -1
field :ability, :integer, default: -1
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a pokemon.
"""
def changeset(pokemon, attrs) do
pokemon
|> cast(attrs, [
:monsterid, :characterid, :level, :exp, :name, :nature, :active,
:accountid, :itemid, :gender, :hpiv, :atkiv, :defiv, :spatkiv,
:spdefiv, :speediv, :evaiv, :acciv, :ability
])
|> validate_required([:monsterid])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.QuestInfo do
@moduledoc """
Ecto schema for the questinfo table.
Represents custom quest info data for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:questinfoid, :id, autogenerate: true}
schema "questinfo" do
field :characterid, :integer, default: 0
field :quest, :integer, default: 0
field :custom_data, :string, source: :customData
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating quest info.
"""
def changeset(quest_info, attrs) do
quest_info
|> cast(attrs, [:characterid, :quest, :custom_data])
|> validate_required([:characterid, :quest])
end
end

View File

@@ -0,0 +1,36 @@
defmodule Odinsea.Database.Schema.QuestStatus do
@moduledoc """
Ecto schema for the queststatus table.
Represents quest progress/status for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:queststatusid, :id, autogenerate: true}
schema "queststatus" do
field :characterid, :integer, default: 0
field :quest, :integer, default: 0
field :status, :integer, default: 0
field :time, :integer, default: 0
field :forfeited, :integer, default: 0
field :custom_data, :string, source: :customData
has_many :quest_status_mobs, Odinsea.Database.Schema.QuestStatusMob, foreign_key: :queststatusid
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating quest status.
"""
def changeset(quest_status, attrs) do
quest_status
|> cast(attrs, [:characterid, :quest, :status, :time, :forfeited, :custom_data])
|> validate_required([:characterid, :quest])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.QuestStatusMob do
@moduledoc """
Ecto schema for the queststatusmobs table.
Represents mob kill counts for active quests.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:queststatusmobid, :id, autogenerate: true}
schema "queststatusmobs" do
field :queststatusid, :integer, default: 0
field :mob, :integer, default: 0
field :count, :integer, default: 0
belongs_to :quest_status, Odinsea.Database.Schema.QuestStatus,
foreign_key: :queststatusid,
references: :queststatusid,
define_field: false
end
@doc """
Changeset for creating/updating quest status mob.
"""
def changeset(quest_status_mob, attrs) do
quest_status_mob
|> cast(attrs, [:queststatusid, :mob, :count])
|> validate_required([:queststatusid, :mob])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.ReactorDrop do
@moduledoc """
Ecto schema for the reactordrops table.
Represents reactor (map object) drop tables.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:reactordropid, :id, autogenerate: true}
schema "reactordrops" do
field :reactorid, :integer
field :itemid, :integer
field :chance, :integer
field :questid, :integer, default: -1
end
@doc """
Changeset for creating/updating a reactor drop.
"""
def changeset(reactor_drop, attrs) do
reactor_drop
|> cast(attrs, [:reactorid, :itemid, :chance, :questid])
|> validate_required([:reactorid, :itemid, :chance])
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.RegrockLocation do
@moduledoc """
Ecto schema for the regrocklocations table.
Represents regular teleport rock locations for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:trockid, :id, autogenerate: true}
schema "regrocklocations" do
field :characterid, :integer
field :mapid, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a regular teleport rock location.
"""
def changeset(regrock_location, attrs) do
regrock_location
|> cast(attrs, [:characterid, :mapid])
|> validate_required([:characterid, :mapid])
end
end

View File

@@ -0,0 +1,26 @@
defmodule Odinsea.Database.Schema.Report do
@moduledoc """
Ecto schema for the reports table.
Represents player reports.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:reportid, :id, autogenerate: true}
schema "reports" do
field :characterid, :integer, default: 0, primary_key: true
field :type, :integer, default: 0
field :count, :integer, default: 0
end
@doc """
Changeset for creating/updating a report.
"""
def changeset(report, attrs) do
report
|> cast(attrs, [:characterid, :type, :count])
|> validate_required([:characterid])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.Ring do
@moduledoc """
Ecto schema for the rings table.
Represents ring (friendship/marriage) data.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:ringid, :id, autogenerate: true}
schema "rings" do
field :partner_ring_id, :integer, default: 0, source: :partnerRingId
field :partner_chr_id, :integer, default: 0, source: :partnerChrId
field :itemid, :integer, default: 0
field :partnername, :string, default: ""
end
@doc """
Changeset for creating/updating a ring.
"""
def changeset(ring, attrs) do
ring
|> cast(attrs, [:partner_ring_id, :partner_chr_id, :itemid, :partnername])
|> validate_required([:itemid])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.SavedLocation do
@moduledoc """
Ecto schema for the savedlocations table.
Represents saved locations for characters (teleport rocks, etc).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "savedlocations" do
field :characterid, :integer
field :locationtype, :integer, default: 0
field :map, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a saved location.
"""
def changeset(saved_location, attrs) do
saved_location
|> cast(attrs, [:characterid, :locationtype, :map])
|> validate_required([:characterid, :map])
end
end

View File

@@ -0,0 +1,37 @@
defmodule Odinsea.Database.Schema.ScrollLog do
@moduledoc """
Ecto schema for the scroll_log table.
Represents scroll usage logs.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "scroll_log" do
field :acc_id, :integer, default: 0, source: :accId
field :chr_id, :integer, default: 0, source: :chrId
field :scroll_id, :integer, default: 0, source: :scrollId
field :item_id, :integer, default: 0, source: :itemId
field :old_slots, :integer, default: 0, source: :oldSlots
field :new_slots, :integer, default: 0, source: :newSlots
field :hammer, :integer, default: 0
field :result, :string, default: ""
field :white_scroll, :integer, default: 0, source: :whiteScroll
field :legendary_spirit, :integer, default: 0, source: :legendarySpirit
field :vega_id, :integer, default: 0, source: :vegaId
end
@doc """
Changeset for creating a scroll log entry.
"""
def changeset(scroll_log, attrs) do
scroll_log
|> cast(attrs, [
:acc_id, :chr_id, :scroll_id, :item_id, :old_slots, :new_slots,
:hammer, :result, :white_scroll, :legendary_spirit, :vega_id
])
|> validate_required([:acc_id, :chr_id, :scroll_id, :item_id])
end
end

View File

@@ -0,0 +1,23 @@
defmodule Odinsea.Database.Schema.Shop do
@moduledoc """
Ecto schema for the shops table.
Represents NPC shop definitions.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:shopid, :id, autogenerate: true}
schema "shops" do
field :npcid, :integer, default: 0
end
@doc """
Changeset for creating/updating a shop.
"""
def changeset(shop, attrs) do
shop
|> cast(attrs, [:npcid])
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.ShopItem do
@moduledoc """
Ecto schema for the shopitems table.
Represents items available in NPC shops.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:shopitemid, :id, autogenerate: true}
schema "shopitems" do
field :shopid, :integer, default: 0
field :itemid, :integer, default: 0
field :price, :integer, default: 0
field :position, :integer, default: 0
field :reqitem, :integer, default: 0
field :reqitemq, :integer, default: 0
field :rank, :integer, default: 0
end
@doc """
Changeset for creating/updating a shop item.
"""
def changeset(shop_item, attrs) do
shop_item
|> cast(attrs, [:shopid, :itemid, :price, :position, :reqitem, :reqitemq, :rank])
|> validate_required([:shopid, :itemid])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.ShopRank do
@moduledoc """
Ecto schema for the shopranks table.
Represents shop rank definitions.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "shopranks" do
field :shopid, :integer, default: 0
field :rank, :integer, default: 0
field :name, :string, default: ""
field :itemid, :integer, default: 0
end
@doc """
Changeset for creating/updating a shop rank.
"""
def changeset(shop_rank, attrs) do
shop_rank
|> cast(attrs, [:shopid, :rank, :name, :itemid])
|> validate_required([:shopid])
end
end

View File

@@ -0,0 +1,25 @@
defmodule Odinsea.Database.Schema.Sidekick do
@moduledoc """
Ecto schema for the sidekicks table.
Represents sidekick (partner) relationships between characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "sidekicks" do
field :firstid, :integer, default: 0
field :secondid, :integer, default: 0
end
@doc """
Changeset for creating a sidekick relationship.
"""
def changeset(sidekick, attrs) do
sidekick
|> cast(attrs, [:firstid, :secondid])
|> validate_required([:firstid, :secondid])
end
end

View File

@@ -0,0 +1,33 @@
defmodule Odinsea.Database.Schema.Skill do
@moduledoc """
Ecto schema for the skills table.
Represents character skill levels.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "skills" do
field :skillid, :integer, default: 0
field :characterid, :integer, default: 0
field :skilllevel, :integer, default: 0
field :masterlevel, :integer, default: 0
field :expiration, :integer, default: -1
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a skill.
"""
def changeset(skill, attrs) do
skill
|> cast(attrs, [:skillid, :characterid, :skilllevel, :masterlevel, :expiration])
|> validate_required([:skillid, :characterid])
end
end

View File

@@ -0,0 +1,32 @@
defmodule Odinsea.Database.Schema.SkillCooldown do
@moduledoc """
Ecto schema for the skills_cooldowns table.
Represents active skill cooldowns for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "skills_cooldowns" do
field :charid, :integer
field :skill_id, :integer, source: :SkillID
field :length, :integer
field :start_time, :integer, source: :StartTime
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :charid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a skill cooldown.
"""
def changeset(skill_cooldown, attrs) do
skill_cooldown
|> cast(attrs, [:charid, :skill_id, :length, :start_time])
|> validate_required([:charid, :skill_id, :length, :start_time])
end
end

View File

@@ -0,0 +1,35 @@
defmodule Odinsea.Database.Schema.SkillMacro do
@moduledoc """
Ecto schema for the skillmacros table.
Represents skill macros (combo skills) for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "skillmacros" do
field :characterid, :integer, default: 0
field :position, :integer, default: 0
field :skill1, :integer, default: 0
field :skill2, :integer, default: 0
field :skill3, :integer, default: 0
field :name, :string
field :shout, :integer, default: 0
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a skill macro.
"""
def changeset(skill_macro, attrs) do
skill_macro
|> cast(attrs, [:characterid, :position, :skill1, :skill2, :skill3, :name, :shout])
|> validate_required([:characterid, :position])
end
end

View File

@@ -0,0 +1,28 @@
defmodule Odinsea.Database.Schema.Speedrun do
@moduledoc """
Ecto schema for the speedruns table.
Represents dungeon speedrun records.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "speedruns" do
field :type, :string
field :leader, :string
field :timestring, :string
field :time, :integer, default: 0
field :members, :string, default: ""
end
@doc """
Changeset for creating a speedrun record.
"""
def changeset(speedrun, attrs) do
speedrun
|> cast(attrs, [:type, :leader, :timestring, :time, :members])
|> validate_required([:type, :leader, :timestring])
end
end

View File

@@ -0,0 +1,31 @@
defmodule Odinsea.Database.Schema.Storage do
@moduledoc """
Ecto schema for the storages table.
Represents account storage (bank) data.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:storageid, :id, autogenerate: true}
schema "storages" do
field :accountid, :integer, default: 0
field :slots, :integer, default: 0
field :meso, :integer, default: 0
belongs_to :account, Odinsea.Database.Schema.Account,
foreign_key: :accountid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating storage.
"""
def changeset(storage, attrs) do
storage
|> cast(attrs, [:accountid, :slots, :meso])
|> validate_required([:accountid])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.TournamentLog do
@moduledoc """
Ecto schema for the tournamentlog table.
Represents tournament records.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:logid, :id, autogenerate: true}
@timestamps_opts [inserted_at: :when, updated_at: false]
schema "tournamentlog" do
field :winnerid, :integer, default: 0
field :num_contestants, :integer, default: 0, source: :numContestants
field :when, :naive_datetime
end
@doc """
Changeset for creating a tournament log entry.
"""
def changeset(tournament_log, attrs) do
tournament_log
|> cast(attrs, [:winnerid, :num_contestants])
|> validate_required([:winnerid])
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.TrockLocation do
@moduledoc """
Ecto schema for the trocklocations table.
Represents teleport rock locations for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:trockid, :id, autogenerate: true}
schema "trocklocations" do
field :characterid, :integer
field :mapid, :integer
belongs_to :character, Odinsea.Database.Schema.Character,
foreign_key: :characterid,
references: :id,
define_field: false
end
@doc """
Changeset for creating/updating a teleport rock location.
"""
def changeset(trock_location, attrs) do
trock_location
|> cast(attrs, [:characterid, :mapid])
|> validate_required([:characterid, :mapid])
end
end

View File

@@ -0,0 +1,24 @@
defmodule Odinsea.Database.Schema.Wishlist do
@moduledoc """
Ecto schema for the wishlist table.
Represents cash shop wishlist items for characters.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "wishlist" do
field :characterid, :integer, primary_key: true
field :sn, :integer, primary_key: true
end
@doc """
Changeset for creating a wishlist entry.
"""
def changeset(wishlist, attrs) do
wishlist
|> cast(attrs, [:characterid, :sn])
|> validate_required([:characterid, :sn])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.WzItemAddData do
@moduledoc """
Ecto schema for the wz_itemadddata table.
Represents additional static item data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_itemadddata" do
field :itemid, :integer
field :key, :string
field :value1, :integer, default: 0
field :value2, :integer, default: 0
end
@doc """
Changeset for WZ item add data.
"""
def changeset(wz_item_add_data, attrs) do
wz_item_add_data
|> cast(attrs, [:itemid, :key, :value1, :value2])
|> validate_required([:itemid, :key])
end
end

View File

@@ -0,0 +1,49 @@
defmodule Odinsea.Database.Schema.WzItemData do
@moduledoc """
Ecto schema for the wz_itemdata table.
Represents static item data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:itemid, :integer, autogenerate: false}
schema "wz_itemdata" do
field :name, :string
field :msg, :string
field :desc, :string
field :slot_max, :integer, default: 1, source: :slotMax
field :price, :string, default: "-1.0"
field :whole_price, :integer, default: -1, source: :wholePrice
field :state_change, :integer, default: 0, source: :stateChange
field :flags, :integer, default: 0
field :karma, :integer, default: 0
field :meso, :integer, default: 0
field :monster_book, :integer, default: 0, source: :monsterBook
field :item_make_level, :integer, default: 0, source: :itemMakeLevel
field :quest_id, :integer, default: 0, source: :questId
field :scroll_reqs, :string, source: :scrollReqs
field :consume_item, :string, source: :consumeItem
field :totalprob, :integer, default: 0
field :inc_skill, :string, default: "", source: :incSkill
field :replaceid, :integer, default: 0
field :replacemsg, :string, default: ""
field :create, :integer, default: 0
field :after_image, :string, default: "", source: :afterImage
end
@doc """
Changeset for WZ item data.
"""
def changeset(wz_item_data, attrs) do
wz_item_data
|> cast(attrs, [
:itemid, :name, :msg, :desc, :slot_max, :price, :whole_price,
:state_change, :flags, :karma, :meso, :monster_book, :item_make_level,
:quest_id, :scroll_reqs, :consume_item, :totalprob, :inc_skill,
:replaceid, :replacemsg, :create, :after_image
])
|> validate_required([:itemid])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.WzItemEquipData do
@moduledoc """
Ecto schema for the wz_itemequipdata table.
Represents static equipment data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_itemequipdata" do
field :itemid, :integer
field :item_level, :integer, default: -1, source: :itemLevel
field :key, :string
field :value, :integer, default: 0
end
@doc """
Changeset for WZ equipment data.
"""
def changeset(wz_item_equip_data, attrs) do
wz_item_equip_data
|> cast(attrs, [:itemid, :item_level, :key, :value])
|> validate_required([:itemid, :key])
end
end

View File

@@ -0,0 +1,30 @@
defmodule Odinsea.Database.Schema.WzItemRewardData do
@moduledoc """
Ecto schema for the wz_itemrewarddata table.
Represents item reward data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_itemrewarddata" do
field :itemid, :integer
field :item, :integer
field :prob, :integer, default: 0
field :quantity, :integer, default: 0
field :period, :integer, default: -1
field :world_msg, :string, default: "", source: :worldMsg
field :effect, :string, default: ""
end
@doc """
Changeset for WZ item reward data.
"""
def changeset(wz_item_reward_data, attrs) do
wz_item_reward_data
|> cast(attrs, [:itemid, :item, :prob, :quantity, :period, :world_msg, :effect])
|> validate_required([:itemid, :item])
end
end

View File

@@ -0,0 +1,43 @@
defmodule Odinsea.Database.Schema.WzMobSkillData do
@moduledoc """
Ecto schema for the wz_mobskilldata table.
Represents monster skill data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_mobskilldata" do
field :skillid, :integer
field :level, :integer
field :hp, :integer, default: 100
field :mpcon, :integer, default: 0
field :x, :integer, default: 1
field :y, :integer, default: 1
field :time, :integer, default: 0
field :prop, :integer, default: 100
field :limit, :integer, default: 0
field :spawneffect, :integer, default: 0
field :interval, :integer, default: 0
field :summons, :string, default: ""
field :ltx, :integer, default: 0
field :lty, :integer, default: 0
field :rbx, :integer, default: 0
field :rby, :integer, default: 0
field :once, :integer, default: 0
end
@doc """
Changeset for WZ mob skill data.
"""
def changeset(wz_mob_skill_data, attrs) do
wz_mob_skill_data
|> cast(attrs, [
:skillid, :level, :hp, :mpcon, :x, :y, :time, :prop, :limit,
:spawneffect, :interval, :summons, :ltx, :lty, :rbx, :rby, :once
])
|> validate_required([:skillid, :level])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Odinsea.Database.Schema.WzOxData do
@moduledoc """
Ecto schema for the wz_oxdata table.
Represents OX quiz questions from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "wz_oxdata" do
field :questionset, :integer, default: 0, primary_key: true
field :questionid, :integer, default: 0, primary_key: true
field :question, :string, default: ""
field :display, :string, default: ""
field :answer, :string
end
@doc """
Changeset for WZ OX data.
"""
def changeset(wz_ox_data, attrs) do
wz_ox_data
|> cast(attrs, [:questionset, :questionid, :question, :display, :answer])
|> validate_required([:questionset, :questionid, :answer])
end
end

View File

@@ -0,0 +1,29 @@
defmodule Odinsea.Database.Schema.WzQuestActData do
@moduledoc """
Ecto schema for the wz_questactdata table.
Represents quest action data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_questactdata" do
field :questid, :integer, default: 0
field :name, :string, default: ""
field :type, :integer, default: 0
field :int_store, :integer, default: 0, source: :intStore
field :applicable_jobs, :string, default: "", source: :applicableJobs
field :uniqueid, :integer, default: 0
end
@doc """
Changeset for WZ quest action data.
"""
def changeset(wz_quest_act_data, attrs) do
wz_quest_act_data
|> cast(attrs, [:questid, :name, :type, :int_store, :applicable_jobs, :uniqueid])
|> validate_required([:questid])
end
end

View File

@@ -0,0 +1,34 @@
defmodule Odinsea.Database.Schema.WzQuestData do
@moduledoc """
Ecto schema for the wz_questdata table.
Represents static quest data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:questid, :integer, autogenerate: false}
schema "wz_questdata" do
field :name, :string, default: ""
field :auto_start, :integer, default: 0, source: :autoStart
field :auto_pre_complete, :integer, default: 0, source: :autoPreComplete
field :view_medal_item, :integer, default: 0, source: :viewMedalItem
field :selected_skill_id, :integer, default: 0, source: :selectedSkillID
field :blocked, :integer, default: 0
field :auto_accept, :integer, default: 0, source: :autoAccept
field :auto_complete, :integer, default: 0, source: :autoComplete
end
@doc """
Changeset for WZ quest data.
"""
def changeset(wz_quest_data, attrs) do
wz_quest_data
|> cast(attrs, [
:questid, :name, :auto_start, :auto_pre_complete, :view_medal_item,
:selected_skill_id, :blocked, :auto_accept, :auto_complete
])
|> validate_required([:questid])
end
end

View File

@@ -0,0 +1,29 @@
defmodule Odinsea.Database.Schema.WzQuestReqData do
@moduledoc """
Ecto schema for the wz_questreqdata table.
Represents quest requirement data from WZ files.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :id, autogenerate: true}
schema "wz_questreqdata" do
field :questid, :integer, default: 0
field :name, :string, default: ""
field :type, :integer, default: 0
field :string_store, :string, default: "", source: :stringStore
field :int_stores_first, :string, default: "", source: :intStoresFirst
field :int_stores_second, :string, default: "", source: :intStoresSecond
end
@doc """
Changeset for WZ quest requirement data.
"""
def changeset(wz_quest_req_data, attrs) do
wz_quest_req_data
|> cast(attrs, [:questid, :name, :type, :string_store, :int_stores_first, :int_stores_second])
|> validate_required([:questid])
end
end

View File

@@ -94,6 +94,10 @@ defmodule Odinsea.Game.Character do
:face,
# GM Level (0 = normal player, >0 = GM)
:gm,
# Guild
:guild_id,
:guild_rank,
:alliance_rank,
# Stats
:stats,
# Position & Map
@@ -134,6 +138,9 @@ defmodule Odinsea.Game.Character do
hair: non_neg_integer(),
face: non_neg_integer(),
gm: non_neg_integer(),
guild_id: non_neg_integer(),
guild_rank: non_neg_integer(),
alliance_rank: non_neg_integer(),
stats: Stats.t(),
map_id: non_neg_integer(),
position: Position.t(),
@@ -281,6 +288,30 @@ defmodule Odinsea.Game.Character do
GenServer.call(via_tuple(character_id), {:drop_item, inv_type, position, quantity})
end
@doc """
Adds meso to the character.
Returns {:ok, new_meso} on success, {:error, reason} on failure.
"""
def gain_meso(character_id, amount, show_in_chat \\ false) do
GenServer.call(via_tuple(character_id), {:gain_meso, amount, show_in_chat})
end
@doc """
Checks if the character has inventory space for an item.
Returns {:ok, slot} with the next free slot, or {:error, :inventory_full}.
"""
def check_inventory_space(character_id, inv_type, quantity \\ 1) do
GenServer.call(via_tuple(character_id), {:check_inventory_space, inv_type, quantity})
end
@doc """
Adds an item to the character's inventory (from a drop).
Returns {:ok, item} on success, {:error, reason} on failure.
"""
def add_item_from_drop(character_id, item) do
GenServer.call(via_tuple(character_id), {:add_item_from_drop, item})
end
# ============================================================================
# GenServer Callbacks
# ============================================================================
@@ -423,6 +454,45 @@ defmodule Odinsea.Game.Character do
end
end
@impl true
def handle_call({:gain_meso, amount, show_in_chat}, _from, state) do
# Cap meso at 9,999,999,999 (MapleStory max)
max_meso = 9_999_999_999
new_meso = min(state.meso + amount, max_meso)
new_state = %{state | meso: new_meso, updated_at: DateTime.utc_now()}
# TODO: Send meso gain packet to client if show_in_chat is true
{:reply, {:ok, new_meso}, new_state}
end
@impl true
def handle_call({:check_inventory_space, inv_type, _quantity}, _from, state) do
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
case Inventory.get_next_free_slot(inventory) do
nil -> {:reply, {:error, :inventory_full}, state}
slot -> {:reply, {:ok, slot}, state}
end
end
@impl true
def handle_call({:add_item_from_drop, item}, _from, state) do
inv_type = get_inventory_type_from_item_id(item.item_id)
inventory = Map.get(state.inventories, inv_type, Inventory.new(inv_type))
case Inventory.add_item(inventory, item) do
{:ok, new_inventory, assigned_item} ->
new_inventories = Map.put(state.inventories, inv_type, new_inventory)
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
{:reply, {:ok, assigned_item}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_cast({:update_position, position}, state) do
new_state = %{
@@ -459,6 +529,19 @@ defmodule Odinsea.Game.Character do
{:via, Registry, {Odinsea.CharacterRegistry, character_id}}
end
defp get_inventory_type_from_item_id(item_id) do
type_prefix = div(item_id, 1_000_000)
case type_prefix do
1 -> :equip
2 -> :use
3 -> :setup
4 -> :etc
5 -> :cash
_ -> :etc
end
end
@doc """
Converts database character to in-game state.
"""
@@ -517,6 +600,9 @@ defmodule Odinsea.Game.Character do
hair: db_char.hair,
face: db_char.face,
gm: db_char.gm,
guild_id: db_char.guild_id || 0,
guild_rank: db_char.guild_rank || 0,
alliance_rank: db_char.alliance_rank || 0,
stats: stats,
map_id: db_char.map_id,
position: position,
@@ -850,4 +936,112 @@ defmodule Odinsea.Game.Character do
# TODO: Use actual MapleStory EXP table
level * level * level + 100 * level
end
# ============================================================================
# Scripting API Helper Functions
# ============================================================================
@doc """
Gets the character's channel ID.
"""
def get_channel(character_id) do
case get_state(character_id) do
nil -> {:error, :character_not_found}
%State{channel_id: channel_id} -> {:ok, channel_id}
end
end
@doc """
Updates the character's meso.
"""
def update_meso(character_id, new_meso) do
GenServer.cast(via_tuple(character_id), {:update_meso, new_meso})
end
@doc """
Updates the character's job.
"""
def update_job(character_id, new_job) do
GenServer.cast(via_tuple(character_id), {:update_job, new_job})
end
@doc """
Updates a skill level.
"""
def update_skill(character_id, skill_id, level, master_level) do
GenServer.cast(via_tuple(character_id), {:update_skill, skill_id, level, master_level})
end
@doc """
Adds an item to inventory.
"""
def add_item(character_id, inventory_type, item) do
GenServer.call(via_tuple(character_id), {:add_item, inventory_type, item})
end
@doc """
Removes items by item ID.
"""
def remove_item_by_id(character_id, item_id, quantity) do
GenServer.call(via_tuple(character_id), {:remove_item_by_id, item_id, quantity})
end
# ============================================================================
# GenServer Callbacks - Scripting Operations
# ============================================================================
@impl true
def handle_cast({:update_meso, new_meso}, state) do
new_state = %{state | meso: new_meso, updated_at: DateTime.utc_now()}
{:noreply, new_state}
end
@impl true
def handle_cast({:update_job, new_job}, state) do
new_state = %{state | job: new_job, updated_at: DateTime.utc_now()}
{:noreply, new_state}
end
@impl true
def handle_cast({:update_skill, skill_id, level, master_level}, state) do
skill_entry = %{
level: level,
master_level: master_level,
expiration: -1
}
new_skills = Map.put(state.skills, skill_id, skill_entry)
new_state = %{state | skills: new_skills, updated_at: DateTime.utc_now()}
{:noreply, new_state}
end
@impl true
def handle_call({:add_item, inventory_type, item}, _from, state) do
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
case Inventory.add_item(inventory, item) do
{:ok, new_inventory} ->
new_inventories = Map.put(state.inventories, inventory_type, new_inventory)
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_call({:remove_item_by_id, item_id, quantity}, _from, state) do
inventory_type = Inventory.get_type_by_item_id(item_id)
inventory = Map.get(state.inventories, inventory_type, Inventory.new(inventory_type))
case Inventory.remove_by_id(inventory, item_id, quantity) do
{:ok, new_inventory, _removed} ->
new_inventories = Map.put(state.inventories, inventory_type, new_inventory)
new_state = %{state | inventories: new_inventories, updated_at: DateTime.utc_now()}
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
end

View File

@@ -14,6 +14,8 @@ defmodule Odinsea.Game.Drop do
- Type 3: Explosive/FFA (instant FFA)
"""
alias Odinsea.Game.Item
@type t :: %__MODULE__{
oid: integer(),
item_id: integer(),
@@ -30,7 +32,9 @@ defmodule Odinsea.Game.Drop do
created_at: integer(),
expire_time: integer() | nil,
public_time: integer() | nil,
dropper_oid: integer() | nil
dropper_oid: integer() | nil,
# Item struct for item drops (nil for meso drops)
item: Item.t() | nil
}
defstruct [
@@ -49,7 +53,8 @@ defmodule Odinsea.Game.Drop do
:created_at,
:expire_time,
:public_time,
:dropper_oid
:dropper_oid,
:item
]
# Default drop expiration times (milliseconds)
@@ -65,6 +70,7 @@ defmodule Odinsea.Game.Drop do
individual_reward = Keyword.get(opts, :individual_reward, false)
dropper_oid = Keyword.get(opts, :dropper_oid, nil)
source_position = Keyword.get(opts, :source_position, nil)
item = Keyword.get(opts, :item, nil)
now = System.system_time(:millisecond)
%__MODULE__{
@@ -83,7 +89,8 @@ defmodule Odinsea.Game.Drop do
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
dropper_oid: dropper_oid,
item: item
}
end
@@ -113,7 +120,8 @@ defmodule Odinsea.Game.Drop do
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
dropper_oid: dropper_oid,
item: nil
}
end
@@ -141,13 +149,22 @@ defmodule Odinsea.Game.Drop do
@doc """
Checks if a drop is visible to a specific character.
Considers quest requirements and individual rewards.
For quest items, the character must have the quest started.
For individual rewards, only the owner can see the drop.
"""
def visible_to?(%__MODULE__{} = drop, character_id, _quest_status) do
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
# Check quest requirement
if drop.quest_id > 0 do
# Only visible if character has quest started (status 1)
Map.get(quest_status, drop.quest_id, 0) == 1
else
true
end
end
end
@@ -158,6 +175,13 @@ defmodule Odinsea.Game.Drop do
drop.meso > 0
end
@doc """
Checks if this is an item drop.
"""
def item?(%__MODULE__{} = drop) do
drop.meso == 0 and drop.item_id > 0
end
@doc """
Gets the display ID (item_id for items, meso amount for meso).
"""
@@ -170,7 +194,13 @@ defmodule Odinsea.Game.Drop do
end
@doc """
Checks if a character can loot this drop.
Checks if a character can loot this drop based on ownership rules.
Drop types:
- 0: Owner only (until timeout)
- 1: Owner's party (until timeout)
- 2: Free-for-all (FFA)
- 3: Explosive (instant FFA)
"""
def can_loot?(%__MODULE__{} = drop, character_id, now) do
# If already picked up, can't loot
@@ -197,4 +227,35 @@ defmodule Odinsea.Game.Drop do
end
end
end
@doc """
Checks if a character can loot this drop, including party check.
Requires party information to validate party drops.
"""
def can_loot_with_party?(%__MODULE__{} = drop, character_id, party_id, party_members, now) do
if drop.picked_up do
false
else
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
owner_in_same_party = drop.owner_id in party_members
(owner_in_same_party and party_id != nil) or
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

@@ -167,6 +167,17 @@ defmodule Odinsea.Game.Inventory do
end
end
@doc """
Gets the next available slot number.
Returns nil if the inventory is full (Elixir-style).
"""
def get_next_free_slot(%__MODULE__{} = inv) do
case next_free_slot(inv) do
-1 -> nil
slot -> slot
end
end
defp find_next_slot(items, limit, slot) when slot > limit, do: -1
defp find_next_slot(items, limit, slot) do
@@ -205,6 +216,23 @@ defmodule Odinsea.Game.Inventory do
end
end
@doc """
Adds a plain map item to the inventory (used for drops).
Returns {:ok, new_inventory, assigned_item} or {:error, :inventory_full}.
"""
def add_item(%__MODULE__{} = inv, %{} = item_map) when not is_struct(item_map) do
slot = next_free_slot(inv)
if slot < 0 do
{:error, :inventory_full}
else
# Convert map to item with assigned position
assigned_item = Map.put(item_map, :position, slot)
new_items = Map.put(inv.items, slot, assigned_item)
{:ok, %{inv | items: new_items}, assigned_item}
end
end
@doc """
Adds an item from the database (preserves position).
"""
@@ -391,4 +419,103 @@ defmodule Odinsea.Game.Inventory do
end
def equipped_items(%__MODULE__{}), do: []
@doc """
Gets the inventory type based on item ID.
"""
def get_type_by_item_id(item_id) do
InventoryType.from_item_id(item_id)
end
@doc """
Checks if inventory has at least the specified quantity of an item.
"""
def has_item_count(%__MODULE__{} = inv, item_id, quantity) do
count_by_id(inv, item_id) >= quantity
end
@doc """
Checks if there's at least one free slot in the inventory.
"""
def has_free_slot(%__MODULE__{} = inv) do
next_free_slot(inv) >= 0
end
@doc """
Checks if inventory can hold the specified quantity of an item.
For stackable items, checks if there's room to stack or a free slot.
"""
def can_hold_quantity(%__MODULE__{} = inv, item_id, quantity) do
# Find existing item to check stack space
existing = find_by_id(inv, item_id)
slot_max = InventoryType.slot_limit(inv.type)
if existing do
# Check if we can stack
space_in_stack = slot_max - existing.quantity
remaining = quantity - space_in_stack
if remaining <= 0 do
true
else
# Need additional slots
free_slots = count_free_slots(inv)
slots_needed = div(remaining, slot_max) + if rem(remaining, slot_max) > 0, do: 1, else: 0
free_slots >= slots_needed
end
else
# Need new slot(s)
free_slots = count_free_slots(inv)
slots_needed = div(quantity, slot_max) + if rem(quantity, slot_max) > 0, do: 1, else: 0
free_slots >= slots_needed
end
end
@doc """
Removes items by item ID.
Returns {:ok, new_inventory, removed_count} or {:error, reason}.
"""
def remove_by_id(%__MODULE__{} = inv, item_id, quantity) do
items_with_id =
inv.items
|> Map.values()
|> Enum.filter(fn item -> item.item_id == item_id end)
|> Enum.sort_by(fn item -> item.position end)
total_available = Enum.map(items_with_id, fn i -> i.quantity end) |> Enum.sum()
if total_available < quantity do
{:error, :insufficient_quantity}
else
{new_items, removed} = do_remove_by_id(inv.items, items_with_id, quantity, 0)
{:ok, %{inv | items: new_items}, removed}
end
end
defp do_remove_by_id(items, _items_to_remove, 0, removed), do: {items, removed}
defp do_remove_by_id(items, [], _quantity, removed), do: {items, removed}
defp do_remove_by_id(items, [item | rest], quantity, removed) do
if quantity <= 0 do
{items, removed}
else
to_remove = min(item.quantity, quantity)
new_quantity = item.quantity - to_remove
new_items = if new_quantity <= 0 do
Map.delete(items, item.position)
else
Map.put(items, item.position, %{item | quantity: new_quantity})
end
do_remove_by_id(new_items, rest, quantity - to_remove, removed + to_remove)
end
end
@doc """
Counts free slots in the inventory.
"""
def count_free_slots(%__MODULE__{} = inv) do
used_slots = map_size(inv.items)
inv.slot_limit - used_slots
end
end

View File

@@ -0,0 +1,120 @@
defmodule Odinsea.Game.InventoryManipulator do
@moduledoc """
High-level inventory operations for adding/removing items.
Ported from Java server.MapleInventoryManipulator
This module provides convenient functions for:
- Adding items from drops
- Adding items by ID
- Removing items
- Checking inventory space
"""
require Logger
alias Odinsea.Game.Character
@doc """
Adds an item to the character's inventory from a drop.
Returns {:ok, item} on success, {:error, reason} on failure.
Ported from MapleInventoryManipulator.addFromDrop()
"""
def add_from_drop(character_pid, item) when is_pid(character_pid) do
Character.add_item_from_drop(character_pid, item)
end
def add_from_drop(character_id, item) when is_integer(character_id) do
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
[{pid, _}] -> add_from_drop(pid, item)
[] -> {:error, :character_not_found}
end
end
@doc """
Adds an item to the character's inventory by item ID and quantity.
Creates a new item instance with default properties.
Ported from MapleInventoryManipulator.addById()
"""
def add_by_id(character_pid, item_id, quantity \\ 1, gm_log \\ "") do
# Create a basic item
item = %{
item_id: item_id,
quantity: quantity,
owner: "",
flag: 0,
gm_log: gm_log
}
add_from_drop(character_pid, item)
end
@doc """
Adds an item to the character's inventory with full item details.
Ported from MapleInventoryManipulator.addbyItem()
"""
def add_by_item(character_pid, item) do
add_from_drop(character_pid, item)
end
@doc """
Removes an item from a specific inventory slot.
Ported from MapleInventoryManipulator.removeFromSlot()
"""
def remove_from_slot(character_pid, inv_type, slot, quantity \\ 1, _from_drop \\ false, _wedding \\ false) do
Character.drop_item(character_pid, inv_type, slot, quantity)
end
@doc """
Removes items by item ID from the inventory.
Ported from MapleInventoryManipulator.removeById()
"""
def remove_by_id(_character_pid, _inv_type, _item_id, _quantity, _delete \\ false, _wedding \\ false) do
# TODO: Implement remove by ID (search for item, then remove)
{:ok, 0}
end
@doc """
Checks if the character has space for an item.
Ported from MapleInventoryManipulator.checkSpace()
"""
def check_space(character_pid, item_id, quantity \\ 1, _owner \\ "") do
inv_type = get_inventory_type(item_id)
case Character.check_inventory_space(character_pid, inv_type, quantity) do
{:ok, _slot} -> true
{:error, _} -> false
end
end
@doc """
Checks if the character's inventory is full.
"""
def inventory_full?(character_pid, inv_type) do
case Character.check_inventory_space(character_pid, inv_type, 1) do
{:ok, _} -> false
{:error, :inventory_full} -> true
end
end
@doc """
Gets the inventory type from an item ID.
"""
def get_inventory_type(item_id) do
type_prefix = div(item_id, 1_000_000)
case type_prefix do
1 -> :equip
2 -> :use
3 -> :setup
4 -> :etc
5 -> :cash
_ -> :etc
end
end
end

View File

@@ -95,4 +95,33 @@ defmodule Odinsea.Game.InventoryType do
Lists all inventory types including equipped.
"""
def all_types, do: [:equip, :use, :setup, :etc, :cash, :equipped]
@doc """
Gets the inventory type from an item ID.
Based on MapleStory item ID ranges:
- 1000000-1999999: Equip
- 2000000-2999999: Use (consumables)
- 3000000-3999999: Setup
- 4000000-4999999: Etc
- 5000000-5999999: Cash
"""
def from_item_id(item_id) when is_integer(item_id) do
cond do
item_id >= 1_000_000 and item_id < 2_000_000 -> :equip
item_id >= 2_000_000 and item_id < 3_000_000 -> :use
item_id >= 3_000_000 and item_id < 4_000_000 -> :setup
item_id >= 4_000_000 and item_id < 5_000_000 -> :etc
item_id >= 5_000_000 and item_id < 6_000_000 -> :cash
true -> :etc
end
end
def from_item_id(_), do: :etc
@doc """
Gets slot limit for an inventory type.
"""
def slot_limit(type) do
default_slot_limit(type)
end
end

View File

@@ -0,0 +1,110 @@
defmodule Odinsea.Game.JobType do
@moduledoc """
Job type definitions for character creation.
Ported from Java LoginInformationProvider.JobType
Job types:
- 0 = Resistance
- 1 = Adventurer
- 2 = Cygnus
- 3 = Aran
- 4 = Evan
"""
@type t :: :resistance | :adventurer | :cygnus | :aran | :evan | :ultimate_adventurer
@doc """
Converts an integer job type to atom.
"""
@spec from_int(integer()) :: t()
def from_int(0), do: :resistance
def from_int(1), do: :adventurer
def from_int(2), do: :cygnus
def from_int(3), do: :aran
def from_int(4), do: :evan
def from_int(_), do: :adventurer
@doc """
Converts a job type atom to integer.
"""
@spec to_int(t()) :: integer()
def to_int(:resistance), do: 0
def to_int(:adventurer), do: 1
def to_int(:cygnus), do: 2
def to_int(:aran), do: 3
def to_int(:evan), do: 4
def to_int(:ultimate_adventurer), do: 5
def to_int(_), do: 1
@doc """
Gets the base job ID for a job type.
"""
@spec get_job_id(t()) :: integer()
def get_job_id(:resistance), do: 3000
def get_job_id(:adventurer), do: 0
def get_job_id(:cygnus), do: 1000
def get_job_id(:aran), do: 2000
def get_job_id(:evan), do: 2001
def get_job_id(:ultimate_adventurer), do: 0
def get_job_id(_), do: 0
@doc """
Checks if a job type is valid for character creation.
"""
@spec valid?(integer() | t()) :: boolean()
def valid?(type) when is_integer(type), do: type >= 0 and type <= 4
def valid?(type) when is_atom(type), do: type in [:resistance, :adventurer, :cygnus, :aran, :evan]
def valid?(_), do: false
@doc """
Gets the tutorial map ID for a job type.
"""
@spec get_tutorial_map(t() | integer()) :: integer()
def get_tutorial_map(:resistance), do: 931000000
def get_tutorial_map(:adventurer), do: 0 # Maple Island (special handling)
def get_tutorial_map(:cygnus), do: 130030000
def get_tutorial_map(:aran), do: 914000000
def get_tutorial_map(:evan), do: 900010000
def get_tutorial_map(type) when is_integer(type) do
type |> from_int() |> get_tutorial_map()
end
def get_tutorial_map(_), do: 100000000 # Default to Henesys
@doc """
Gets the beginner guide book item ID for a job type.
"""
@spec get_guide_book(t() | integer()) :: integer() | nil
def get_guide_book(:resistance), do: 4161001
def get_guide_book(:adventurer), do: 4161001
def get_guide_book(:cygnus), do: 4161047
def get_guide_book(:aran), do: 4161048
def get_guide_book(:evan), do: 4161052
def get_guide_book(type) when is_integer(type) do
type |> from_int() |> get_guide_book()
end
def get_guide_book(_), do: nil
@doc """
Gets the initial quests for a job type.
Returns a list of {quest_id, status, custom_data} tuples.
"""
@spec get_initial_quests(t() | integer()) :: list()
def get_initial_quests(:cygnus) do
[
{20022, 1, "1"},
{20010, 1, nil}
]
end
def get_initial_quests(:ultimate_adventurer) do
# Complete all explorer quests (2490-2507)
base_quests = Enum.map(2490..2507, fn qid -> {qid, 2, nil} end)
[
{29947, 2, nil}
| base_quests
]
end
def get_initial_quests(type) when is_integer(type) do
type |> from_int() |> get_initial_quests()
end
def get_initial_quests(_), do: []
end

View File

@@ -16,7 +16,7 @@ defmodule Odinsea.Game.Map do
require Logger
alias Odinsea.Game.Character
alias Odinsea.Game.{MapFactory, LifeFactory, Monster, Reactor, ReactorFactory}
alias Odinsea.Game.{Drop, MapFactory, LifeFactory, Monster, Reactor, ReactorFactory}
alias Odinsea.Channel.Packets, as: ChannelPackets
# ============================================================================
@@ -1073,11 +1073,19 @@ defmodule Odinsea.Game.Map do
@doc """
Attempts to pick up a drop.
Returns {:ok, drop} if successful, {:error, reason} if not.
"""
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
@doc """
Checks if a drop is visible to a character (for quest items, individual rewards).
"""
def drop_visible_to?(map_id, channel_id, drop_oid, character_id, quest_status \\ %{}) do
GenServer.call(via_tuple(map_id, channel_id), {:drop_visible_to, drop_oid, character_id, quest_status})
end
@impl true
def handle_call(:get_drops, _from, state) do
{:reply, state.items, state}
@@ -1092,24 +1100,41 @@ defmodule Odinsea.Game.Map do
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}
# Validate ownership using Drop.can_loot?
if not Drop.can_loot?(drop, character_id, now) do
{:reply, {:error, :not_owner}, state}
else
case DropSystem.pickup_drop(drop, character_id, now) do
{:ok, updated_drop} ->
# Broadcast pickup animation to all players
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
end
@impl true
def handle_call({:drop_visible_to, drop_oid, character_id, quest_status}, _from, state) do
case Map.get(state.items, drop_oid) do
nil ->
{:reply, false, state}
drop ->
visible = Drop.visible_to?(drop, character_id, quest_status)
{:reply, visible, state}
end
end
@impl true
def handle_info(:check_drop_expiration, state) do
now = System.system_time(:millisecond)

View File

@@ -18,6 +18,7 @@ defmodule Odinsea.Login.Handler do
alias Odinsea.Login.Packets
alias Odinsea.Constants.Server
alias Odinsea.Database.Context
alias Odinsea.Game.{JobType, InventoryType}
# ==================================================================================================
# Permission Request (Client Hello / Version Check)
@@ -78,70 +79,111 @@ defmodule Odinsea.Login.Handler do
Logger.info("Login attempt: username=#{username} from #{state.ip}")
# Check if IP/MAC is banned
# TODO: Implement IP/MAC ban checking
is_banned = false
ip_banned = Context.ip_banned?(state.ip)
mac_banned = Context.mac_banned?(state.mac)
# Authenticate with database
case Context.authenticate_user(username, password, state.ip) do
{:ok, account_info} ->
# TODO: Check if account is banned or temp banned
# TODO: Check if already logged in (kick other session)
if (ip_banned || mac_banned) do
Logger.warning("Banned IP/MAC attempted login: ip=#{state.ip}, mac=#{state.mac}")
# If MAC banned, also ban the IP for enforcement
if mac_banned do
Context.ban_ip_address(state.ip, "Enforcing account ban, account #{username}", false, 4)
end
response = Packets.get_login_failed(3)
send_packet(state, response)
{:ok, state}
else
# Authenticate with database
case Context.authenticate_user(username, password, state.ip) do
{:ok, account_info} ->
# Check if account is banned (perm or temp)
temp_ban_info = Context.get_temp_ban_info(account_info.account_id)
if temp_ban_info do
Logger.warning("Temp banned account attempted login: #{username}")
response = Packets.get_temp_ban(
format_timestamp(temp_ban_info.expires),
temp_ban_info.reason || ""
)
send_packet(state, response)
{:ok, state}
else
# Check if already logged in - kick other session
login_state = Context.get_login_state(account_info.account_id)
if login_state > 0 do
Logger.warning("Account already logged in, kicking other session: #{username}")
# Kick the existing session
kick_existing_session(account_info.account_id, username)
# Small delay to allow kick to process
Process.sleep(100)
end
# Update login state to logged in
Context.update_login_state(account_info.account_id, 2, state.ip)
# Send success response
response = Packets.get_auth_success(
account_info.account_id,
account_info.username,
account_info.gender,
account_info.is_gm,
account_info.second_password
)
# Send success response
response = Packets.get_auth_success(
account_info.account_id,
account_info.username,
account_info.gender,
account_info.is_gm,
account_info.second_password
)
new_state =
state
|> Map.put(:logged_in, true)
|> Map.put(:account_id, account_info.account_id)
|> Map.put(:account_name, account_info.username)
|> Map.put(:gender, account_info.gender)
|> Map.put(:is_gm, account_info.is_gm)
|> Map.put(:second_password, account_info.second_password)
|> Map.put(:login_attempts, 0)
new_state =
state
|> Map.put(:logged_in, true)
|> Map.put(:account_id, account_info.account_id)
|> Map.put(:account_name, account_info.username)
|> Map.put(:gender, account_info.gender)
|> Map.put(:is_gm, account_info.is_gm)
|> Map.put(:second_password, account_info.second_password)
|> Map.put(:login_attempts, 0)
send_packet(state, response)
{:ok, new_state}
send_packet(state, response)
{:ok, new_state}
end
{:error, :invalid_credentials} ->
# Increment login attempts
login_attempts = Map.get(state, :login_attempts, 0) + 1
{:error, :invalid_credentials} ->
# Increment login attempts
login_attempts = Map.get(state, :login_attempts, 0) + 1
if login_attempts > 5 do
Logger.warning("Too many login attempts from #{state.ip}")
{:disconnect, :too_many_attempts}
else
# Send login failed (reason 4 = incorrect password)
response = Packets.get_login_failed(4)
if login_attempts > 5 do
Logger.warning("Too many login attempts from #{state.ip}")
{:disconnect, :too_many_attempts}
else
# Send login failed (reason 4 = incorrect password)
response = Packets.get_login_failed(4)
send_packet(state, response)
new_state = Map.put(state, :login_attempts, login_attempts)
{:ok, new_state}
end
{:error, :account_not_found} ->
# Send login failed (reason 5 = not registered ID)
response = Packets.get_login_failed(5)
send_packet(state, response)
{:ok, state}
new_state = Map.put(state, :login_attempts, login_attempts)
{:ok, new_state}
end
{:error, :already_logged_in} ->
# Try to kick the existing session and allow retry
Logger.warning("Already logged in, attempting to kick session for: #{username}")
kick_existing_session_by_name(username)
Process.sleep(100)
# Send login failed (reason 7 = already logged in) but client can retry
response = Packets.get_login_failed(7)
send_packet(state, response)
{:ok, state}
{:error, :account_not_found} ->
# Send login failed (reason 5 = not registered ID)
response = Packets.get_login_failed(5)
send_packet(state, response)
{:ok, state}
{:error, :already_logged_in} ->
# Send login failed (reason 7 = already logged in)
response = Packets.get_login_failed(7)
send_packet(state, response)
{:ok, state}
{:error, :banned} ->
# TODO: Check temp ban vs perm ban
response = Packets.get_perm_ban(0)
send_packet(state, response)
{:ok, state}
{:error, :banned} ->
response = Packets.get_perm_ban(0)
send_packet(state, response)
{:ok, state}
end
end
end
@@ -245,8 +287,11 @@ defmodule Odinsea.Login.Handler do
# {:ok, state}
# else
# TODO: Load character list from database
# Load character list from database
characters = load_characters(state.account_id, world_id)
# Store character IDs in state for later validation
char_ids = Enum.map(characters, & &1.id)
response = Packets.get_char_list(
characters,
@@ -260,6 +305,7 @@ defmodule Odinsea.Login.Handler do
state
|> Map.put(:world, world_id)
|> Map.put(:channel, actual_channel)
|> Map.put(:character_ids, char_ids)
{:ok, new_state}
end
@@ -282,7 +328,7 @@ defmodule Odinsea.Login.Handler do
else
{char_name, _packet} = In.decode_string(packet)
# TODO: Check if name is forbidden or already exists
# Check if name is forbidden or already exists
name_used = check_name_used(char_name, state)
response = Packets.get_char_name_response(char_name, name_used)
@@ -318,8 +364,8 @@ defmodule Odinsea.Login.Handler do
{:disconnect, :not_logged_in}
else
{name, packet} = In.decode_string(packet)
{job_type, packet} = In.decode_int(packet)
{_dual_blade, packet} = In.decode_short(packet)
{job_type_int, packet} = In.decode_int(packet)
{dual_blade, packet} = In.decode_short(packet)
{gender, packet} = In.decode_byte(packet)
{face, packet} = In.decode_int(packet)
{hair, packet} = In.decode_int(packet)
@@ -330,17 +376,64 @@ defmodule Odinsea.Login.Handler do
{shoes, packet} = In.decode_int(packet)
{weapon, _packet} = In.decode_int(packet)
Logger.info("Create character: name=#{name}, job_type=#{job_type}")
# TODO: Validate appearance items
# TODO: Create character in database
# TODO: Add default items and quests
# For now, send success stub
response = Packets.get_add_new_char_entry(%{}, false) # TODO: Pass actual character
send_packet(state, response)
{:ok, state}
Logger.info("Create character: name=#{name}, job_type=#{job_type_int}")
# Validate name is not forbidden and doesn't exist
if check_name_used(name, state) do
response = Packets.get_add_new_char_entry(nil, false)
send_packet(state, response)
{:ok, state}
else
# TODO: Validate appearance items are eligible for gender/job type
# For now, accept the items as provided
# Create character with default stats
job_type = JobType.from_int(job_type_int)
default_stats = Context.get_default_stats_for_job(job_type_int, dual_blade)
default_map = Context.get_default_map_for_job(job_type_int)
# Combine hair with hair color
final_hair = hair + hair_color
# Build character attributes
attrs = Map.merge(default_stats, %{
name: name,
accountid: state.account_id,
world: state.world,
face: face,
hair: final_hair,
gender: gender,
skin: skin_color,
map: default_map
})
case Context.create_character(attrs) do
{:ok, character} ->
# Add default items to character inventory
:ok = add_default_items(character.id, top, bottom, shoes, weapon, job_type_int)
# Add job-specific starter items and quests
:ok = add_job_specific_starters(character.id, job_type_int)
Logger.info("Character created successfully: id=#{character.id}, name=#{name}")
# Reload character with full data
char_data = Context.load_character(character.id)
response = Packets.get_add_new_char_entry(char_data, true)
send_packet(state, response)
# Add character ID to state's character list
new_char_ids = [character.id | Map.get(state, :character_ids, [])]
new_state = Map.put(state, :character_ids, new_char_ids)
{:ok, new_state}
{:error, changeset} ->
Logger.error("Failed to create character: #{inspect(changeset.errors)}")
response = Packets.get_add_new_char_entry(nil, false)
send_packet(state, response)
{:ok, state}
end
end
end
end
@@ -366,28 +459,70 @@ defmodule Odinsea.Login.Handler do
Handles character deletion.
Packet structure:
- byte: has_spw (1 if second password provided)
- string: second_password (if enabled)
- string: asia_password (legacy, usually empty)
- int: character_id
"""
def on_delete_character(packet, state) do
if not Map.get(state, :logged_in, false) do
{:disconnect, :not_logged_in}
else
# TODO: Read second password if enabled
{_spw, packet} = In.decode_string(packet)
{has_spw, packet} = In.decode_byte(packet)
# Read second password if enabled
{spw, packet} =
if has_spw > 0 do
In.decode_string(packet)
else
{"", packet}
end
{_asia_pw, packet} = In.decode_string(packet)
{character_id, _packet} = In.decode_int(packet)
Logger.info("Delete character: character_id=#{character_id}")
Logger.info("Delete character: character_id=#{character_id}, account=#{state.account_name}")
# Validate second password if account has one
spw_valid = validate_second_password(state, spw)
result =
cond do
not spw_valid ->
Logger.warning("Delete character: invalid second password")
12 # Wrong Password
not character_belongs_to_account?(character_id, state) ->
Logger.warning("Delete character: character does not belong to account")
1 # General error
true ->
# Attempt to delete character
case Context.delete_character(character_id) do
:ok ->
Logger.info("Character deleted successfully: id=#{character_id}")
# Remove from state's character list
0 # Success
{:error, reason} ->
Logger.error("Failed to delete character: #{inspect(reason)}")
1 # General error
end
end
# TODO: Validate second password
# TODO: Check if character belongs to account
# TODO: Delete character from database
# For now, send success stub
response = Packets.get_delete_char_response(character_id, 0)
response = Packets.get_delete_char_response(character_id, result)
send_packet(state, response)
# Update state if successful
new_state =
if result == 0 do
new_char_ids = Enum.reject(Map.get(state, :character_ids, []), &(&1 == character_id))
Map.put(state, :character_ids, new_char_ids)
else
state
end
{:ok, state}
{:ok, new_state}
end
end
@@ -400,30 +535,84 @@ defmodule Odinsea.Login.Handler do
Initiates migration to the selected channel.
Packet structure:
- byte: set_spw (1 if setting second password)
- int: character_id
"""
def on_select_character(packet, state) do
if not Map.get(state, :logged_in, false) do
{:disconnect, :not_logged_in}
else
{set_spw, packet} = In.decode_byte(packet)
{character_id, _packet} = In.decode_int(packet)
Logger.info("Select character: character_id=#{character_id}, channel=#{state.channel}")
# TODO: Validate character belongs to account
# TODO: Load character data
# TODO: Register migration token with channel server
# Send migration command to connect to channel
# TODO: Get actual channel IP/port
channel_ip = "127.0.0.1"
channel_port = 8585 + (state.channel - 1)
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
send_packet(state, response)
new_state = Map.put(state, :character_id, character_id)
{:ok, new_state}
# Validate character belongs to account
unless character_belongs_to_account?(character_id, state) do
Logger.warning("Select character: character does not belong to account")
{:disconnect, :invalid_character}
else
# Handle setting second password if requested
if set_spw > 0 do
{new_spw, _} = In.decode_string(packet)
# Validate second password length
if String.length(new_spw) < 6 || String.length(new_spw) > 16 do
response = Packets.get_second_pw_error(0x14)
send_packet(state, response)
{:ok, state}
else
# Update second password
Context.update_second_password(state.account_id, new_spw)
Logger.info("Second password set for account: #{state.account_name}")
# Continue with character selection
do_character_migration(character_id, state)
end
else
do_character_migration(character_id, state)
end
end
end
end
defp do_character_migration(character_id, state) do
# Load character data
case Context.load_character(character_id) do
nil ->
Logger.error("Failed to load character: id=#{character_id}")
{:disconnect, :character_not_found}
character ->
# Register migration token with channel server
migration_token = generate_migration_token()
# Store migration info in Redis/ETS for channel server
:ok = register_migration_token(
migration_token,
character_id,
state.account_id,
state.channel
)
# Update login state to server transition
Context.update_login_state(state.account_id, 1, state.ip)
# Get channel IP and port
{channel_ip, channel_port} = get_channel_endpoint(state.channel)
Logger.info("Character migration: char=#{character.name} to channel #{state.channel} (#{channel_ip}:#{channel_port})")
# Send migration command
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
send_packet(state, response)
new_state =
state
|> Map.put(:character_id, character_id)
|> Map.put(:migration_token, migration_token)
{:ok, new_state}
end
end
@@ -436,21 +625,27 @@ defmodule Odinsea.Login.Handler do
Packet structure:
- string: second_password
- int: character_id
"""
def on_check_spw_request(packet, state) do
{spw, _packet} = In.decode_string(packet)
# TODO: Validate second password
stored_spw = Map.get(state, :second_password)
if stored_spw == nil or stored_spw == spw do
# Success - continue with operation
{:ok, state}
{spw, packet} = In.decode_string(packet)
{character_id, _packet} = In.decode_int(packet)
# Validate character belongs to account
unless character_belongs_to_account?(character_id, state) do
{:disconnect, :invalid_character}
else
# Failure - send error
response = Packets.get_second_pw_error(15) # Incorrect SPW
send_packet(state, response)
{:ok, state}
stored_spw = Map.get(state, :second_password)
if stored_spw == nil or stored_spw == spw do
# Success - migrate to channel
do_character_migration(character_id, state)
else
# Failure - send error
response = Packets.get_second_pw_error(15) # Incorrect SPW
send_packet(state, response)
{:ok, state}
end
end
end
@@ -535,4 +730,163 @@ defmodule Odinsea.Login.Handler do
Context.forbidden_name?(char_name) or
Context.character_name_exists?(char_name)
end
defp character_belongs_to_account?(character_id, state) do
char_ids = Map.get(state, :character_ids, [])
character_id in char_ids
end
defp validate_second_password(state, provided_spw) do
stored_spw = Map.get(state, :second_password)
# If no second password set, accept any
if stored_spw == nil || stored_spw == "" do
true
else
stored_spw == provided_spw
end
end
defp kick_existing_session(account_id, username) do
# TODO: Implement session kicking via World server or Redis pub/sub
# For now, just update login state to force disconnect on next tick
Context.update_login_state(account_id, 0)
# Publish kick message to Redis for other servers
Odinsea.Database.Redis.publish("kick_session", %{account_id: account_id, username: username})
:ok
end
defp kick_existing_session_by_name(username) do
# Find account by name and kick
case Context.get_account_by_name(username) do
nil -> :ok
account -> kick_existing_session(account.id, username)
end
end
defp add_default_items(character_id, top, bottom, shoes, weapon, _job_type) do
# Add equipped items
Context.create_inventory_item(character_id, :equipped, %{
item_id: top,
position: -5,
quantity: 1
})
if bottom > 0 do
Context.create_inventory_item(character_id, :equipped, %{
item_id: bottom,
position: -6,
quantity: 1
})
end
Context.create_inventory_item(character_id, :equipped, %{
item_id: shoes,
position: -7,
quantity: 1
})
Context.create_inventory_item(character_id, :equipped, %{
item_id: weapon,
position: -11,
quantity: 1
})
# Add starter potions
Context.create_inventory_item(character_id, :use, %{
item_id: 2000013,
position: 0,
quantity: 100
})
Context.create_inventory_item(character_id, :use, %{
item_id: 2000014,
position: 0,
quantity: 100
})
:ok
end
defp add_job_specific_starters(character_id, job_type) do
case job_type do
0 -> # Resistance
Context.create_inventory_item(character_id, :etc, %{
item_id: 4161001,
position: 0,
quantity: 1
})
1 -> # Adventurer
Context.create_inventory_item(character_id, :etc, %{
item_id: 4161001,
position: 0,
quantity: 1
})
2 -> # Cygnus
# Add starter quests
Context.set_quest_progress(character_id, 20022, 1, "1")
Context.set_quest_progress(character_id, 20010, 1, nil)
Context.create_inventory_item(character_id, :etc, %{
item_id: 4161047,
position: 0,
quantity: 1
})
3 -> # Aran
Context.create_inventory_item(character_id, :etc, %{
item_id: 4161048,
position: 0,
quantity: 1
})
4 -> # Evan
Context.create_inventory_item(character_id, :etc, %{
item_id: 4161052,
position: 0,
quantity: 1
})
_ ->
:ok
end
:ok
end
defp generate_migration_token do
# Generate a unique migration token
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
end
defp register_migration_token(token, character_id, account_id, channel) do
# Store in Redis with TTL for channel server to pick up
Odinsea.Database.Redis.setex(
"migration:#{token}",
30, # 30 second TTL
Jason.encode!(%{
character_id: character_id,
account_id: account_id,
channel: channel,
timestamp: System.system_time(:second)
})
)
:ok
end
defp get_channel_endpoint(channel) do
# TODO: Get actual channel IP from World server config
# For now, return localhost with calculated port
ip = Application.get_env(:odinsea, :channel_ip, "127.0.0.1")
base_port = Application.get_env(:odinsea, :channel_base_port, 8585)
port = base_port + (channel - 1)
{ip, port}
end
defp format_timestamp(naive_datetime) do
NaiveDateTime.to_string(naive_datetime)
end
end

View File

@@ -222,6 +222,21 @@ defmodule Odinsea.Net.Cipher.LoginCrypto do
end
end
@doc """
Hashes a second password (PIC) using SHA-1.
Used for second password storage.
## Parameters
- password: Plain text second password
## Returns
- Hex-encoded SHA-1 hash (lowercase)
"""
@spec hash_second_password(String.t()) :: String.t()
def hash_second_password(password) when is_binary(password) do
hex_sha1(password)
end
# Private helper: hash a string with a given digest algorithm
@spec hash_with_digest(String.t(), atom()) :: String.t()
defp hash_with_digest(input, digest) do

View File

@@ -279,6 +279,10 @@ defmodule Odinsea.Net.Opcodes do
# General
def lp_alive_req(), do: 0x0D
def lp_enable_action(), do: 0x0C
def lp_set_field(), do: 0x14
def lp_set_cash_shop_opened(), do: 0x15
def lp_migrate_command(), do: 0x16
# Login
def lp_login_status(), do: 0x01

File diff suppressed because it is too large Load Diff