Files
odinsea-elixir/lib/odinsea/database/context.ex
2026-02-25 12:26:26 -07:00

1981 lines
48 KiB
Elixir

defmodule Odinsea.Database.Context do
@moduledoc """
Database context module for Odinsea.
Provides high-level database operations for accounts, characters, and related entities.
Ported from Java UnifiedDB.java and MapleClient.java login functionality.
"""
require Logger
import Ecto.Query
alias Odinsea.Repo
alias Odinsea.Database.Schema.{
Account,
Character,
CharacterSlot,
InventoryItem,
Buddy,
Guild,
Skill,
QuestStatus,
QuestStatusMob,
QuestInfo,
Gift,
NxCode,
IpBan,
MacBan
}
alias Odinsea.Game.InventoryType
alias Odinsea.Net.Cipher.LoginCrypto
# ==================================================================================================
# Account Operations
# ==================================================================================================
@doc """
Authenticates a user with username and password.
Returns:
- {:ok, account_info} on successful authentication
- {:error, reason} on failure (reason can be :invalid_credentials, :banned, :already_logged_in, etc.)
Ported from MapleClient.java login() method
"""
def authenticate_user(username, password, ip_address \\ "") do
# Filter username (sanitization)
username = sanitize_username(username)
case Repo.get_by(Account, name: username) do
nil ->
Logger.warning("Login attempt for non-existent account: #{username}")
{:error, :account_not_found}
account ->
check_account_login(account, password, ip_address)
end
end
@doc """
Gets an account by name.
Returns nil if not found.
"""
def get_account_by_name(name) do
Repo.get_by(Account, name: name)
end
@doc """
Gets an account by ID.
Returns nil if not found.
"""
def get_account(account_id) do
Repo.get(Account, account_id)
end
@doc """
Updates all accounts to logged out state.
Used during server startup/shutdown.
"""
def set_all_accounts_logged_off do
Repo.update_all(Account, set: [loggedin: 0])
:ok
end
@doc """
Updates account login state.
States:
- 0 = LOGIN_NOTLOGGEDIN
- 1 = LOGIN_SERVER_TRANSITION (migrating between servers)
- 2 = LOGIN_LOGGEDIN
- 3 = CHANGE_CHANNEL
"""
def update_login_state(account_id, state, session_ip \\ nil) do
updates = [loggedin: state]
updates = if session_ip, do: Keyword.put(updates, :session_ip, session_ip), else: updates
Repo.update_all(
from(a in Account, where: a.id == ^account_id),
set: updates
)
:ok
end
@doc """
Updates account logged in status.
"""
def update_logged_in_status(account_id, status) do
Repo.update_all(
from(a in Account, where: a.id == ^account_id),
set: [loggedin: status]
)
:ok
end
@doc """
Gets the current login state for an account.
"""
def get_login_state(account_id) do
case Repo.get(Account, account_id) do
nil -> 0
account -> account.loggedin
end
end
@doc """
Updates account last login time.
"""
def update_last_login(account_id) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Repo.update_all(
from(a in Account, where: a.id == ^account_id),
set: [lastlogin: now]
)
:ok
end
@doc """
Bans an account.
Options:
- :banned - ban status (1 = banned, 2 = auto-banned)
- :banreason - reason for ban
- :tempban - temporary ban expiry datetime
- :greason - GM reason code
"""
def ban_account(account_id, attrs \\ %{}) do
case Repo.get(Account, account_id) do
nil ->
{:error, :not_found}
account ->
account
|> Account.ban_changeset(attrs)
|> Repo.update()
end
end
@doc """
Bans an account with explicit reason and duration (in days).
For permanent ban, set duration to nil or 0.
"""
def ban_account(account_id, reason, duration) do
attrs = %{
banned: 1,
banreason: reason
}
attrs =
if duration && duration > 0 do
tempban =
NaiveDateTime.utc_now()
|> NaiveDateTime.add(duration * 86400, :second)
|> NaiveDateTime.truncate(:second)
Map.put(attrs, :tempban, tempban)
else
attrs
end
ban_account(account_id, attrs)
end
@doc """
Checks if an IP address is banned.
Returns true if the IP is in the ipbans table.
"""
def ip_banned?(ip_address) do
IpBan
|> where([i], i.ip == ^ip_address)
|> Repo.exists?()
end
@doc """
Checks if a MAC address is banned.
Returns true if the MAC is in the macbans table.
"""
def mac_banned?(mac_address) do
MacBan
|> where([m], m.mac == ^mac_address)
|> Repo.exists?()
end
@doc """
Alias for log_ip_address/2.
Also known as ban_ip_address in some contexts.
"""
def ban_ip_address(ip_address, _reason \\ "", _is_mac \\ false, _times \\ 1) do
# For enforcement, we insert into ipbans table
# The original Java code logs and potentially bans
%IpBan{}
|> IpBan.changeset(%{ip: ip_address})
|> Repo.insert()
|> case do
{:ok, _} -> :ok
{:error, _} -> :error
end
end
@doc """
Updates the second password (SPW) for an account.
Input: account_id, hashed_password
"""
def update_second_password(account_id, hashed_password) do
Repo.update_all(
from(a in Account, where: a.id == ^account_id),
set: [second_password: hashed_password]
)
:ok
end
@doc """
Records an IP log entry for audit purposes.
"""
def log_ip_address(account_id, ip_address) do
timestamp = format_timestamp(NaiveDateTime.utc_now())
# Using raw SQL since iplog may not have an Ecto schema yet
sql = "INSERT INTO iplog (accid, ip, time) VALUES (?, ?, ?)"
case Ecto.Adapters.SQL.query(Repo, sql, [account_id, ip_address, timestamp]) do
{:ok, _} ->
:ok
{:error, err} ->
Logger.error("Failed to log IP: #{inspect(err)}")
:error
end
end
@doc """
Checks if an account is banned.
Returns {:ok, account} if not banned, {:error, :banned} if banned.
"""
def check_ban_status(account_id) do
case Repo.get(Account, account_id) do
nil ->
{:error, :not_found}
account ->
if account.banned > 0 do
{:error, :banned}
else
{:ok, account}
end
end
end
@doc """
Gets temporary ban information if account is temp banned.
"""
def get_temp_ban_info(account_id) do
case Repo.get(Account, account_id) do
nil ->
nil
account ->
if account.tempban &&
NaiveDateTime.compare(account.tempban, NaiveDateTime.utc_now()) == :gt do
%{reason: account.banreason, expires: account.tempban}
else
nil
end
end
end
@doc """
Updates account NX cash (ACash) and Maple Points.
"""
def update_account_cash(account_id, acash, mpoints, points \\ nil, vpoints \\ nil) do
updates = [acash: acash, mpoints: mpoints]
updates = if points, do: Keyword.put(updates, :points, points), else: updates
updates = if vpoints, do: Keyword.put(updates, :vpoints, vpoints), else: updates
Repo.update_all(
from(a in Account, where: a.id == ^account_id),
set: updates
)
:ok
end
# ==================================================================================================
# Cash Shop Operations
# ==================================================================================================
@doc """
Gets coupon information by code.
Input: coupon_code string
Output: {:ok, coupon} or {:error, reason}
"""
def get_coupon_info(coupon_code) do
case Repo.get(NxCode, coupon_code) do
nil -> {:error, :not_found}
coupon -> {:ok, coupon}
end
end
@doc """
Marks a coupon as used by an account.
Input: coupon_id (code string), account_id
Output: {:ok, _} or {:error, _}
"""
def mark_coupon_used(coupon_id, account_id) do
case Repo.get(NxCode, coupon_id) do
nil ->
{:error, :not_found}
coupon ->
# Mark as invalid (0) and set user to account name
# We need to look up the account name first
account = Repo.get(Account, account_id)
user_name = if account, do: account.name, else: ""
coupon
|> NxCode.changeset(%{valid: 0, user: user_name})
|> Repo.update()
end
end
@doc """
Increments character slots for an account in a world.
Input: account_id, amount (default 1)
Output: {:ok, new_slot_count} or {:error, _}
"""
def increment_character_slots(account_id, amount \\ 1, world_id \\ 0) do
case CharacterSlot
|> where([c], c.accid == ^account_id and c.worldid == ^world_id)
|> Repo.one() do
nil ->
# Create new slot record with default + amount
%CharacterSlot{}
|> CharacterSlot.changeset(%{
accid: account_id,
worldid: world_id,
charslots: 6 + amount
})
|> Repo.insert()
|> case do
{:ok, slot} -> {:ok, slot.charslots}
error -> error
end
slot ->
# Update existing
new_count = slot.charslots + amount
slot
|> CharacterSlot.changeset(%{charslots: new_count})
|> Repo.update()
|> case do
{:ok, _} -> {:ok, new_count}
error -> error
end
end
end
@doc """
Loads gifts for an account.
Input: account_id (used as recipient ID)
Output: {:ok, gifts} or {:error, _}
"""
def load_gifts(account_id) do
gifts =
Gift
|> where([g], g.recipient == ^account_id)
|> Repo.all()
{:ok, gifts}
end
@doc """
Creates a new gift (cash shop gift from one character to another).
## Parameters
- attrs: Map with keys:
- :recipient (required) - Account ID of the recipient
- :from (required) - Name of the sender
- :message (optional) - Gift message
- :sn (optional) - Cash shop serial number
- :uniqueid (optional) - Unique item ID
Output: {:ok, gift} or {:error, changeset}
"""
def create_gift(attrs) do
%Gift{}
|> Gift.changeset(attrs)
|> Repo.insert()
end
@doc """
Creates a new gift with explicit parameters.
Alternative to create_gift/1 for direct parameter passing.
## Parameters
- recipient_id: Account ID of the recipient
- from_name: Name of the sender
- message: Gift message
- sn: Cash shop serial number
- uniqueid: Unique item ID
"""
def create_gift(recipient_id, from_name, message \\ "", sn \\ 0, uniqueid \\ 0) do
attrs = %{
recipient: recipient_id,
from: from_name,
message: message,
sn: sn,
uniqueid: uniqueid
}
create_gift(attrs)
end
# ==================================================================================================
# Character Operations
# ==================================================================================================
@doc """
Loads character entries for an account in a specific world.
Returns a list of character summaries (id, name, gm level).
Ported from UnifiedDB.loadCharactersEntry()
"""
def load_character_entries(account_id, world_id) do
Character
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|> select([c], %{id: c.id, name: c.name, gm: c.gm})
|> Repo.all()
end
@doc """
Gets the count of characters for an account in a world.
"""
def character_count(account_id, world_id) do
Character
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|> Repo.aggregate(:count, :id)
end
@doc """
Gets all character IDs for an account in a world.
"""
def load_character_ids(account_id, world_id) do
Character
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|> select([c], c.id)
|> Repo.all()
end
@doc """
Gets all character names for an account in a world.
"""
def load_character_names(account_id, world_id) do
Character
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|> select([c], c.name)
|> Repo.all()
end
@doc """
Gets all characters for an account in a world.
Returns full Character structs.
"""
def get_characters_by_account(account_id, world_id) do
Character
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|> Repo.all()
end
@doc """
Loads full character data by ID.
Returns the Character struct or nil if not found.
TODO: Expand to load related data (inventory, skills, quests, etc.)
"""
def load_character(character_id) do
Repo.get(Character, character_id)
end
@doc """
Gets a character by ID.
Alias for load_character/1 with {:ok, character} or {:error, reason} return.
"""
def get_character(character_id) do
case Repo.get(Character, character_id) do
nil -> {:error, :not_found}
character -> {:ok, character}
end
end
@doc """
Updates character data.
Input: character_id, attrs map
Uses a generic update changeset that allows updating any character field.
"""
def update_character(character_id, attrs) do
case Repo.get(Character, character_id) do
nil ->
{:error, :not_found}
character ->
# Use a generic changeset that casts all valid character fields
changeset =
Ecto.Changeset.cast(character, attrs, [
:name,
:level,
:exp,
:str,
:dex,
:luk,
:int,
:hp,
:mp,
:maxhp,
:maxmp,
:ap,
:meso,
:fame,
:job,
:skincolor,
:gender,
:hair,
:face,
:map,
:spawnpoint,
:gm,
:party,
:buddy_capacity,
:guildid,
:guildrank,
:alliance_rank,
:guild_contribution,
:pets,
:sp,
:subcategory,
:rank,
:rank_move,
:job_rank,
:job_rank_move,
:marriage_id,
:familyid,
:seniorid,
:junior1,
:junior2,
:currentrep,
:totalrep,
:gachexp,
:fatigue,
:charm,
:craft,
:charisma,
:will,
:sense,
:insight,
:total_wins,
:total_losses,
:pvp_exp,
:pvp_points
])
Repo.update(changeset)
end
end
@doc """
Checks if a character name is already in use.
"""
def character_name_exists?(name) do
Character
|> where([c], c.name == ^name)
|> Repo.exists?()
end
@doc """
Gets character ID by name and world.
"""
def get_character_id(name, world_id) do
Character
|> where([c], c.name == ^name and c.world == ^world_id)
|> select([c], c.id)
|> Repo.one()
end
@doc """
Gets a character by name.
"""
def get_character_by_name(name) do
Repo.get_by(Character, name: name)
end
@doc """
Creates a new character.
TODO: Add initial items, quests, and stats based on job type
"""
def create_character(attrs) do
%Character{}
|> Character.creation_changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes a character (soft delete - renames and moves to deleted world).
Returns {:ok, character} on success, {:error, reason} on failure.
Ported from UnifiedDB.deleteCharacter()
"""
def delete_character(character_id) do
# TODO: Check guild rank (can't delete if guild leader)
# TODO: Remove from family
# TODO: Handle sidekick
# WORLD_DELETED
deleted_world = -1
# Soft delete: rename with # prefix and move to deleted world
# Need to get the character name first to construct the new name
case Repo.get(Character, character_id) do
nil ->
{:error, :not_found}
character ->
new_name = "#" <> character.name
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [
name: new_name,
world: deleted_world
]
)
# Clean up related records
cleanup_character_assets(character_id)
:ok
end
end
@doc """
Updates character stats.
"""
def update_character_stats(character_id, attrs) do
case Repo.get(Character, character_id) do
nil ->
{:error, :not_found}
character ->
character
|> Character.stat_changeset(attrs)
|> Repo.update()
end
end
@doc """
Updates character position (map, spawn point).
"""
def update_character_position(character_id, map_id, spawn_point) do
case Repo.get(Character, character_id) do
nil ->
{:error, :not_found}
character ->
character
|> Character.position_changeset(%{map: map_id, spawnpoint: spawn_point})
|> Repo.update()
end
end
@doc """
Updates character meso.
"""
def update_character_meso(character_id, meso) do
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [meso: meso]
)
:ok
end
@doc """
Updates character EXP.
"""
def update_character_exp(character_id, exp) do
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [exp: exp]
)
:ok
end
@doc """
Updates character job.
"""
def update_character_job(character_id, job_id) do
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [job: job_id]
)
:ok
end
@doc """
Updates character level.
"""
def update_character_level(character_id, level) do
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [level: level]
)
:ok
end
@doc """
Updates character guild information.
"""
def update_character_guild(character_id, guild_id, guild_rank \\ nil) do
updates = [guildid: guild_id]
updates = if guild_rank, do: Keyword.put(updates, :guildrank, guild_rank), else: updates
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: updates
)
:ok
end
@doc """
Updates character SP (skill points).
"""
def update_character_sp(character_id, sp_list) do
sp_string = Enum.join(sp_list, ",")
Repo.update_all(
from(c in Character, where: c.id == ^character_id),
set: [sp: sp_string]
)
:ok
end
# ==================================================================================================
# Character Creation Helpers
# ==================================================================================================
@doc """
Gets default stats for a new character based on job type.
Job types:
- 0 = Resistance
- 1 = Adventurer
- 2 = Cygnus
- 3 = Aran
- 4 = Evan
"""
def get_default_stats_for_job(job_type, subcategory \\ 0) do
base_stats = %{
level: 1,
exp: 0,
hp: 50,
mp: 5,
maxhp: 50,
maxmp: 5,
str: 12,
dex: 5,
luk: 4,
int: 4,
ap: 0
}
case job_type do
# Resistance
0 ->
%{base_stats | job: 3000, str: 12, dex: 5, int: 4, luk: 4}
# Adventurer
1 ->
# Dual Blade
if subcategory == 1 do
%{base_stats | job: 430, str: 4, dex: 25, int: 4, luk: 4}
else
%{base_stats | job: 0, str: 12, dex: 5, int: 4, luk: 4}
end
# Cygnus
2 ->
%{base_stats | job: 1000, str: 12, dex: 5, int: 4, luk: 4}
# Aran
3 ->
%{base_stats | job: 2000, str: 12, dex: 5, int: 4, luk: 4}
# Evan
4 ->
%{base_stats | job: 2001, str: 4, dex: 4, int: 12, luk: 5}
_ ->
base_stats
end
end
@doc """
Gets the default map ID for a job type.
"""
def get_default_map_for_job(job_type) do
case job_type do
# Resistance tutorial
0 -> 931_000_000
# Adventurer - Maple Island (handled specially)
1 -> 0
# Cygnus tutorial
2 -> 130_030_000
# Aran tutorial
3 -> 914_000_000
# Evan tutorial
4 -> 900_010_000
# Default to Henesys
_ -> 100_000_000
end
end
# ==================================================================================================
# Forbidden Names
# ==================================================================================================
@doc """
Checks if a character name is forbidden.
"""
def forbidden_name?(name) do
forbidden = [
"admin",
"gm",
"gamemaster",
"moderator",
"mod",
"owner",
"developer",
"dev",
"support",
"help",
"system",
"server",
"odinsea",
"maplestory",
"nexon"
]
name_lower = String.downcase(name)
Enum.any?(forbidden, fn forbidden ->
String.contains?(name_lower, forbidden)
end)
end
# ==================================================================================================
# Private Functions
# ==================================================================================================
defp check_account_login(account, password, ip_address) do
# Check if banned
if account.banned > 0 && account.gm == 0 do
Logger.warning("Banned account attempted login: #{account.name}")
{:error, :banned}
else
# Login state check is handled by the handler (stale session kicking)
# not here - Java's CharLoginHandler.OnCheckPassword does the same
verify_password(account, password, ip_address)
end
end
defp verify_password(account, password, ip_address) do
# Check various password formats
valid =
check_salted_sha512(account.password, password, account.salt) ||
check_plain_match(account.password, password) ||
check_admin_bypass(password, ip_address)
if valid do
# Log successful login
log_ip_address(account.id, ip_address)
update_last_login(account.id)
# Build account info map
account_info = %{
account_id: account.id,
username: account.name,
gender: account.gender,
is_gm: account.gm > 0,
second_password: decrypt_second_password(account.second_password, account.second_salt),
acash: account.acash,
mpoints: account.mpoints
}
{:ok, account_info}
else
{:error, :invalid_credentials}
end
end
defp check_salted_sha512(_hash, _password, nil), do: false
defp check_salted_sha512(_hash, _password, ""), do: false
defp check_salted_sha512(hash, password, salt) do
# Use LoginCrypto to verify salted SHA-512 hash
case LoginCrypto.verify_salted_sha512(password, salt, hash) do
{:ok, _} -> true
_ -> false
end
end
defp check_plain_match(hash, password) do
# Direct comparison (legacy/insecure, but needed for compatibility)
hash == password
end
defp check_admin_bypass(password, ip_address) do
# Check for admin bypass password from specific IPs
# TODO: Load admin passwords and allowed IPs from config
false
end
defp decrypt_second_password(nil, _), do: nil
defp decrypt_second_password("", _), do: nil
defp decrypt_second_password(spw, second_salt) do
if second_salt && second_salt != "" do
# Decrypt using rand_r (reverse of rand_s)
LoginCrypto.rand_r(spw)
else
spw
end
end
defp sanitize_username(username) do
# Remove potentially dangerous characters
username
|> String.trim()
|> String.replace(~r/[<>"'%;()&+\-]/, "")
end
defp format_timestamp(naive_datetime) do
NaiveDateTime.to_string(naive_datetime)
end
defp cleanup_character_assets(character_id) do
# Clean up pokemon, buddies, etc.
# Using raw SQL for tables that don't have schemas yet
try do
Ecto.Adapters.SQL.query(Repo, "DELETE FROM buddies WHERE buddyid = ?", [character_id])
rescue
_ -> :ok
end
:ok
end
# ==================================================================================================
# Inventory Operations
# ==================================================================================================
@doc """
Loads all inventory items for a character.
Returns a map of inventory types to lists of items.
Ported from ItemLoader.java
"""
def load_character_inventory(character_id) do
items =
InventoryItem
|> where([i], i.characterid == ^character_id)
|> Repo.all()
# Group by inventory type
items
|> Enum.map(&InventoryItem.to_game_item/1)
|> Enum.group_by(fn item ->
db_item = Enum.find(items, fn db -> db.inventoryitemid == item.id end)
InventoryType.from_type(db_item.inventorytype)
end)
end
@doc """
Gets items for a specific inventory type.
"""
def get_inventory_items(character_id, inv_type) do
type_value = InventoryType.type_value(inv_type)
InventoryItem
|> where([i], i.characterid == ^character_id and i.inventorytype == ^type_value)
|> Repo.all()
|> Enum.map(&InventoryItem.to_game_item/1)
end
@doc """
Gets all inventory items for a character (raw database records).
"""
def get_inventory_by_character(character_id) do
InventoryItem
|> where([i], i.characterid == ^character_id)
|> Repo.all()
end
@doc """
Gets a single inventory item by ID.
"""
def get_inventory_item(item_id) do
case Repo.get(InventoryItem, item_id) do
nil -> nil
db_item -> InventoryItem.to_game_item(db_item)
end
end
@doc """
Creates a new inventory item.
"""
def create_inventory_item(character_id, inv_type, item) do
attrs = InventoryItem.from_game_item(item, character_id, inv_type)
%InventoryItem{}
|> InventoryItem.changeset(attrs)
|> Repo.insert()
end
@doc """
Saves an inventory item (inserts or updates).
"""
def save_inventory_item(character_id, inv_type, item) do
attrs = InventoryItem.from_game_item(item, character_id, inv_type)
if item.id do
# Update existing
case Repo.get(InventoryItem, item.id) do
nil ->
%InventoryItem{}
|> InventoryItem.changeset(attrs)
|> Repo.insert()
db_item ->
db_item
|> InventoryItem.changeset(attrs)
|> Repo.update()
end
else
# Insert new
%InventoryItem{}
|> InventoryItem.changeset(attrs)
|> Repo.insert()
end
end
@doc """
Updates an existing inventory item.
"""
def update_inventory_item(item_id, updates) do
case Repo.get(InventoryItem, item_id) do
nil ->
{:error, :not_found}
db_item ->
db_item
|> InventoryItem.changeset(updates)
|> Repo.update()
end
end
@doc """
Updates an inventory item's position.
"""
def update_inventory_item_position(item_id, position) do
Repo.update_all(
from(i in InventoryItem, where: i.inventoryitemid == ^item_id),
set: [position: position]
)
:ok
end
@doc """
Deletes an inventory item.
"""
def delete_inventory_item(item_id) do
Repo.delete_all(from(i in InventoryItem, where: i.inventoryitemid == ^item_id))
:ok
end
@doc """
Saves all inventory items for a character.
Used during character save.
"""
def save_character_inventory(character_id, inventories) do
Enum.each(inventories, fn {inv_type, inventory} ->
Enum.each(inventory.items, fn {_pos, item} ->
attrs = InventoryItem.from_game_item(item, character_id, inv_type)
if item.id do
# Update existing
update_inventory_item(item.id, attrs)
else
# Insert new
create_inventory_item(character_id, inv_type, item)
end
end)
end)
:ok
end
@doc """
Gets the count of items for a character.
"""
def count_inventory_items(character_id) do
InventoryItem
|> where([i], i.characterid == ^character_id)
|> Repo.aggregate(:count, :inventoryitemid)
end
# ==================================================================================================
# Buddy Operations
# ==================================================================================================
@doc """
Adds a buddy request.
"""
def add_buddy(character_id, buddy_id, group_name \\ "ETC") do
attrs = %{
characterid: character_id,
buddyid: buddy_id,
pending: 1,
groupname: group_name
}
%Buddy{}
|> Buddy.changeset(attrs)
|> Repo.insert()
end
@doc """
Accepts a buddy request (sets pending to 0).
"""
def accept_buddy(character_id, buddy_id) do
Repo.update_all(
from(b in Buddy,
where: b.characterid == ^character_id and b.buddyid == ^buddy_id
),
set: [pending: 0]
)
:ok
end
@doc """
Deletes a buddy relationship.
"""
def delete_buddy(character_id, buddy_id) do
Repo.delete_all(
from(b in Buddy,
where:
(b.characterid == ^character_id and b.buddyid == ^buddy_id) or
(b.characterid == ^buddy_id and b.buddyid == ^character_id)
)
)
:ok
end
@doc """
Gets all buddies for a character.
"""
def get_buddies_by_character(character_id) do
Buddy
|> where([b], b.characterid == ^character_id)
|> Repo.all()
end
@doc """
Checks if two characters are buddies.
"""
def are_buddies?(character_id, buddy_id) do
Buddy
|> where([b], b.characterid == ^character_id and b.buddyid == ^buddy_id and b.pending == 0)
|> Repo.exists?()
end
@doc """
Gets pending buddy requests for a character.
"""
def get_pending_buddies(character_id) do
Buddy
|> where([b], b.buddyid == ^character_id and b.pending == 1)
|> Repo.all()
end
# ==================================================================================================
# Guild Operations
# ==================================================================================================
@doc """
Creates a new guild.
"""
def create_guild(attrs) do
%Guild{}
|> Guild.creation_changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a guild.
"""
def update_guild(guild_id, attrs) do
case Repo.get(Guild, guild_id) do
nil ->
{:error, :not_found}
guild ->
guild
|> Guild.settings_changeset(attrs)
|> Repo.update()
end
end
@doc """
Updates guild leader.
"""
def update_guild_leader(guild_id, leader_id) do
case Repo.get(Guild, guild_id) do
nil ->
{:error, :not_found}
guild ->
guild
|> Guild.leader_changeset(%{leader: leader_id})
|> Repo.update()
end
end
@doc """
Deletes a guild.
"""
def delete_guild(guild_id) do
case Repo.get(Guild, guild_id) do
nil ->
{:error, :not_found}
guild ->
Repo.delete(guild)
end
end
@doc """
Gets a guild by ID.
"""
def get_guild_by_id(guild_id) do
Repo.get(Guild, guild_id)
end
@doc """
Gets a guild by name.
"""
def get_guild_by_name(name) do
Repo.get_by(Guild, name: name)
end
@doc """
Updates guild GP (guild points).
"""
def update_guild_gp(guild_id, gp) do
Repo.update_all(
from(g in Guild, where: g.guildid == ^guild_id),
set: [gp: gp]
)
:ok
end
@doc """
Updates guild capacity.
"""
def update_guild_capacity(guild_id, capacity) do
Repo.update_all(
from(g in Guild, where: g.guildid == ^guild_id),
set: [capacity: capacity]
)
:ok
end
@doc """
Updates guild logo/emblem.
"""
def update_guild_logo(guild_id, logo, logo_color, logo_bg, logo_bg_color) do
Repo.update_all(
from(g in Guild, where: g.guildid == ^guild_id),
set: [
logo: logo,
logo_color: logo_color,
logo_bg: logo_bg,
logo_bg_color: logo_bg_color
]
)
:ok
end
@doc """
Checks if a guild name already exists.
"""
def guild_name_exists?(name) do
Guild
|> where([g], g.name == ^name)
|> Repo.exists?()
end
# ==================================================================================================
# Quest Operations
# ==================================================================================================
@doc """
Starts a quest for a character (inserts new quest status).
Status values:
- 0 = Not started
- 1 = In progress
- 2 = Completed
"""
def start_quest(character_id, quest_id, attrs \\ %{}) do
defaults = %{
characterid: character_id,
quest: quest_id,
status: 1,
time: 0,
forfeited: 0,
custom_data: nil
}
attrs = Map.merge(defaults, attrs)
%QuestStatus{}
|> QuestStatus.changeset(attrs)
|> Repo.insert()
end
@doc """
Completes a quest for a character.
"""
def complete_quest(character_id, quest_id, completion_time \\ nil) do
time = if completion_time, do: completion_time, else: System.system_time(:second)
Repo.update_all(
from(q in QuestStatus,
where: q.characterid == ^character_id and q.quest == ^quest_id
),
set: [status: 2, time: time]
)
:ok
end
@doc """
Forfeits a quest for a character.
"""
def forfeit_quest(character_id, quest_id) do
Repo.update_all(
from(q in QuestStatus,
where: q.characterid == ^character_id and q.quest == ^quest_id
),
set: [status: 0]
)
:ok
end
@doc """
Updates quest custom data.
"""
def update_quest_custom_data(character_id, quest_id, custom_data) do
Repo.update_all(
from(q in QuestStatus,
where: q.characterid == ^character_id and q.quest == ^quest_id
),
set: [custom_data: custom_data]
)
:ok
end
@doc """
Updates quest mob kills.
"""
def update_quest_mob_kills(quest_status_id, mob_id, count) do
# Check if entry exists
existing =
QuestStatusMob
|> where([m], m.queststatusid == ^quest_status_id and m.mob == ^mob_id)
|> Repo.one()
if existing do
Repo.update_all(
from(m in QuestStatusMob,
where: m.queststatusid == ^quest_status_id and m.mob == ^mob_id
),
set: [count: count]
)
else
%QuestStatusMob{}
|> QuestStatusMob.changeset(%{
queststatusid: quest_status_id,
mob: mob_id,
count: count
})
|> Repo.insert()
end
:ok
end
@doc """
Gets all quest status records for a character.
"""
def get_quests_by_character(character_id) do
QuestStatus
|> where([q], q.characterid == ^character_id)
|> Repo.all()
end
@doc """
Gets a specific quest status for a character.
"""
def get_quest_status(character_id, quest_id) do
QuestStatus
|> where([q], q.characterid == ^character_id and q.quest == ^quest_id)
|> Repo.one()
end
@doc """
Sets quest progress for a character.
Input: character_id, quest_id, progress_type, value
progress_type: 0 = info number, 1 = info ex, 2 = custom data (all stored in questinfo table)
Output: {:ok, _} or {:error, _}
"""
def set_quest_progress(character_id, quest_id, _progress_type, value) do
# Check if quest info entry exists
existing =
QuestInfo
|> where([q], q.characterid == ^character_id and q.quest == ^quest_id)
|> Repo.one()
case existing do
nil ->
# Create new quest info entry
%QuestInfo{}
|> QuestInfo.changeset(%{
characterid: character_id,
quest: quest_id,
custom_data: to_string(value)
})
|> Repo.insert()
qi ->
# Update existing
qi
|> QuestInfo.changeset(%{custom_data: to_string(value)})
|> Repo.update()
end
end
@doc """
Gets mob kills for a quest status.
"""
def get_quest_mob_kills(quest_status_id) do
QuestStatusMob
|> where([m], m.queststatusid == ^quest_status_id)
|> Repo.all()
end
@doc """
Deletes a quest status and its mob kills.
"""
def delete_quest_status(character_id, quest_id) do
# Get the quest status first to delete mob kills
case get_quest_status(character_id, quest_id) do
nil ->
:ok
qs ->
# Delete mob kills
Repo.delete_all(from(m in QuestStatusMob, where: m.queststatusid == ^qs.queststatusid))
# Delete quest status
Repo.delete_all(
from(q in QuestStatus,
where: q.characterid == ^character_id and q.quest == ^quest_id
)
)
:ok
end
end
# ==================================================================================================
# Skill Operations
# ==================================================================================================
@doc """
Learns a new skill for a character.
"""
def learn_skill(character_id, skill_id, skill_level, master_level \\ 0, expiration \\ -1) do
attrs = %{
characterid: character_id,
skillid: skill_id,
skilllevel: skill_level,
masterlevel: master_level,
expiration: expiration
}
%Skill{}
|> Skill.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a skill level for a character.
"""
def update_skill_level(character_id, skill_id, skill_level, master_level \\ nil) do
updates = [skilllevel: skill_level]
updates = if master_level, do: Keyword.put(updates, :masterlevel, master_level), else: updates
Repo.update_all(
from(s in Skill,
where: s.characterid == ^character_id and s.skillid == ^skill_id
),
set: updates
)
:ok
end
@doc """
Saves a skill (inserts or updates).
"""
def save_skill(character_id, skill_id, skill_level, master_level \\ 0, expiration \\ -1) do
case Repo.one(
from(s in Skill, where: s.characterid == ^character_id and s.skillid == ^skill_id)
) do
nil ->
learn_skill(character_id, skill_id, skill_level, master_level, expiration)
skill ->
skill
|> Skill.changeset(%{
skilllevel: skill_level,
masterlevel: master_level,
expiration: expiration
})
|> Repo.update()
end
end
@doc """
Gets all skills for a character.
"""
def get_skills_by_character(character_id) do
Skill
|> where([s], s.characterid == ^character_id)
|> Repo.all()
end
@doc """
Gets a specific skill for a character.
"""
def get_skill(character_id, skill_id) do
Skill
|> where([s], s.characterid == ^character_id and s.skillid == ^skill_id)
|> Repo.one()
end
@doc """
Deletes a skill from a character.
"""
def delete_skill(character_id, skill_id) do
Repo.delete_all(
from(s in Skill,
where: s.characterid == ^character_id and s.skillid == ^skill_id
)
)
:ok
end
@doc """
Deletes all skills for a character.
"""
def delete_all_skills(character_id) do
Repo.delete_all(from(s in Skill, where: s.characterid == ^character_id))
:ok
end
# ==================================================================================================
# Pet Operations
# ==================================================================================================
@doc """
Saves (creates or updates) a pet.
Uses raw SQL since pets table may not have an Ecto schema yet.
"""
def save_pet(pet) do
sql = """
INSERT INTO pets (petid, name, level, closeness, fullness, seconds, flags)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
level = VALUES(level),
closeness = VALUES(closeness),
fullness = VALUES(fullness),
seconds = VALUES(seconds),
flags = VALUES(flags)
"""
case Ecto.Adapters.SQL.query(
Repo,
sql,
[
pet.unique_id,
pet.name,
pet.level,
pet.closeness,
pet.fullness,
pet.seconds_left,
pet.flags
]
) do
{:ok, _} ->
:ok
{:error, err} ->
Logger.error("Failed to save pet: #{inspect(err)}")
{:error, err}
end
end
@doc """
Creates a new pet.
"""
def create_pet(
pet_id,
name,
level \\ 1,
closeness \\ 0,
fullness \\ 100,
seconds_left \\ 0,
flags \\ 0
) do
sql =
"INSERT INTO pets (petid, name, level, closeness, fullness, seconds, flags) VALUES (?, ?, ?, ?, ?, ?, ?)"
case Ecto.Adapters.SQL.query(
Repo,
sql,
[pet_id, name, level, closeness, fullness, seconds_left, flags]
) do
{:ok, _} ->
:ok
{:error, err} ->
Logger.error("Failed to create pet: #{inspect(err)}")
{:error, err}
end
end
@doc """
Gets a pet by ID.
"""
def get_pet(pet_id) do
sql = "SELECT * FROM pets WHERE petid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [pet_id]) do
{:ok, result} ->
if result.num_rows > 0 do
row = hd(result.rows)
columns = Enum.map(result.columns, &String.to_atom/1)
Enum.zip(columns, row)
|> Map.new()
else
nil
end
{:error, err} ->
Logger.error("Failed to get pet: #{inspect(err)}")
nil
end
end
@doc """
Gets all pets for a character.
"""
def get_pets_by_character(character_id) do
# Pets are linked through inventory items
sql = """
SELECT p.* FROM pets p
JOIN inventoryitems i ON p.petid = i.petid
WHERE i.characterid = ? AND i.petid > -1
"""
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, result} ->
Enum.map(result.rows, fn row ->
columns = Enum.map(result.columns, &String.to_atom/1)
Enum.zip(columns, row) |> Map.new()
end)
{:error, err} ->
Logger.error("Failed to get pets: #{inspect(err)}")
[]
end
end
@doc """
Updates pet closeness (pet affection).
"""
def update_pet_closeness(pet_id, closeness) do
sql = "UPDATE pets SET closeness = ? WHERE petid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [closeness, pet_id]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
@doc """
Updates pet level.
"""
def update_pet_level(pet_id, level) do
sql = "UPDATE pets SET level = ? WHERE petid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [level, pet_id]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
@doc """
Updates pet fullness.
"""
def update_pet_fullness(pet_id, fullness) do
sql = "UPDATE pets SET fullness = ? WHERE petid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [fullness, pet_id]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
# ==================================================================================================
# Saved Locations Operations
# ==================================================================================================
@doc """
Saves a saved location for a character.
"""
def save_saved_location(character_id, location_type, map_id) do
sql = """
INSERT INTO savedlocations (characterid, locationtype, map)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE map = VALUES(map)
"""
case Ecto.Adapters.SQL.query(Repo, sql, [character_id, location_type, map_id]) do
{:ok, _} ->
:ok
{:error, err} ->
Logger.error("Failed to save location: #{inspect(err)}")
{:error, err}
end
end
@doc """
Gets a saved location for a character.
"""
def get_saved_location(character_id, location_type) do
sql = "SELECT map FROM savedlocations WHERE characterid = ? AND locationtype = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id, location_type]) do
{:ok, result} ->
if result.num_rows > 0 do
[[map_id]] = result.rows
map_id
else
nil
end
{:error, _} ->
nil
end
end
@doc """
Gets all saved locations for a character.
"""
def get_saved_locations(character_id) do
sql = "SELECT locationtype, map FROM savedlocations WHERE characterid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, result} ->
Enum.map(result.rows, fn [type, map] -> {type, map} end)
|> Map.new()
{:error, _} ->
%{}
end
end
# ==================================================================================================
# Cooldown Operations
# ==================================================================================================
@doc """
Saves a skill cooldown.
"""
def save_skill_cooldown(character_id, skill_id, start_time, length) do
sql = """
INSERT INTO skills_cooldowns (charid, SkillID, StartTime, length)
VALUES (?, ?, ?, ?)
"""
case Ecto.Adapters.SQL.query(Repo, sql, [character_id, skill_id, start_time, length]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
@doc """
Gets all skill cooldowns for a character.
"""
def get_skill_cooldowns(character_id) do
sql = "SELECT * FROM skills_cooldowns WHERE charid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, result} ->
Enum.map(result.rows, fn row ->
columns = Enum.map(result.columns, &String.to_atom/1)
Enum.zip(columns, row) |> Map.new()
end)
{:error, _} ->
[]
end
end
@doc """
Deletes all skill cooldowns for a character.
"""
def clear_skill_cooldowns(character_id) do
sql = "DELETE FROM skills_cooldowns WHERE charid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
# ==================================================================================================
# Inventory Slot Operations
# ==================================================================================================
@doc """
Saves inventory slot limits for a character.
"""
def save_inventory_slots(character_id, equip, use, setup, etc, cash) do
sql = """
INSERT INTO inventoryslot (characterid, equip, use, setup, etc, cash)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
equip = VALUES(equip),
use = VALUES(use),
setup = VALUES(setup),
etc = VALUES(etc),
cash = VALUES(cash)
"""
case Ecto.Adapters.SQL.query(Repo, sql, [character_id, equip, use, setup, etc, cash]) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
@doc """
Gets inventory slot limits for a character.
"""
def get_inventory_slots(character_id) do
sql = "SELECT equip, use, setup, etc, cash FROM inventoryslot WHERE characterid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, result} ->
if result.num_rows > 0 do
[[equip, use, setup, etc, cash]] = result.rows
%{equip: equip, use: use, setup: setup, etc: etc, cash: cash}
else
%{equip: 24, use: 80, setup: 80, etc: 80, cash: 40}
end
{:error, _} ->
%{equip: 24, use: 80, setup: 80, etc: 80, cash: 40}
end
end
# ==================================================================================================
# Key Layout Operations
# ==================================================================================================
@doc """
Saves key layout for a character.
"""
def save_key_layout(character_id, key_layout) do
# Delete existing
sql_delete = "DELETE FROM keymap WHERE characterid = ?"
Ecto.Adapters.SQL.query(Repo, sql_delete, [character_id])
# Insert new keys
sql_insert = "INSERT INTO keymap (characterid, `key`, `type`, `action`) VALUES (?, ?, ?, ?)"
Enum.each(key_layout, fn {key, {type, action}} ->
Ecto.Adapters.SQL.query(Repo, sql_insert, [character_id, key, type, action])
end)
:ok
end
@doc """
Gets key layout for a character.
"""
def get_key_layout(character_id) do
sql = "SELECT `key`, `type`, `action` FROM keymap WHERE characterid = ?"
case Ecto.Adapters.SQL.query(Repo, sql, [character_id]) do
{:ok, result} ->
Enum.map(result.rows, fn [key, type, action] ->
{key, {type, action}}
end)
|> Map.new()
{:error, _} ->
%{}
end
end
end