Start repo, claude & kimi still vibing tho

This commit is contained in:
ra
2026-02-14 17:04:21 -07:00
commit f5b8aeb39d
54 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
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}
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 """
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 """
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 """
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
# ==================================================================================================
# 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 """
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 """
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 """
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
deleted_world = -1 # WORLD_DELETED
# 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
# ==================================================================================================
# 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
0 -> # Resistance
%{base_stats | job: 3000, str: 12, dex: 5, int: 4, luk: 4}
1 -> # Adventurer
if subcategory == 1 do # Dual Blade
%{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
2 -> # Cygnus
%{base_stats | job: 1000, str: 12, dex: 5, int: 4, luk: 4}
3 -> # Aran
%{base_stats | job: 2000, str: 12, dex: 5, int: 4, luk: 4}
4 -> # Evan
%{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
0 -> 931000000 # Resistance tutorial
1 -> 0 # Adventurer - Maple Island (handled specially)
2 -> 130030000 # Cygnus tutorial
3 -> 914000000 # Aran tutorial
4 -> 900010000 # Evan tutorial
_ -> 100000000 # Default to Henesys
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
# Check login state
login_state = account.loggedin
if login_state > 0 do
# Already logged in - could check if stale session
Logger.warning("Account already logged in: #{account.name}")
{:error, :already_logged_in}
else
verify_password(account, password, ip_address)
end
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.salt2),
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, salt2) do
if salt2 && salt2 != "" 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
end