278 lines
7.4 KiB
Elixir
278 lines
7.4 KiB
Elixir
defmodule Odinsea.World.Migration do
|
|
@moduledoc """
|
|
Character migration system for server transfers.
|
|
Manages transfer tokens when moving between login/channel/cash shop servers.
|
|
|
|
Ported from Java handling.world.CharacterTransfer and World.ChannelChange_Data
|
|
|
|
In the Java version, this uses Redis for cross-server communication.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
alias Odinsea.Database.Context
|
|
|
|
@token_ttl 60 # Token expires after 60 seconds
|
|
|
|
@doc """
|
|
Creates a migration token for character transfer.
|
|
|
|
Called when:
|
|
- Player selects character (login -> channel)
|
|
- Player changes channel (channel -> channel)
|
|
- Player enters cash shop (channel -> cash shop)
|
|
|
|
## Parameters
|
|
- character_id: Character ID being transferred
|
|
- account_id: Account ID
|
|
- source_server: :login, :channel, or :shop
|
|
- target_channel: Target channel ID (or -10 for cash shop, -20 for MTS)
|
|
- character_data: Optional character state to preserve during transfer
|
|
|
|
## Returns
|
|
- {:ok, token_id} on success
|
|
- {:error, reason} on failure
|
|
"""
|
|
def create_migration_token(character_id, account_id, source_server, target_channel, character_data \\ %{}) do
|
|
token_id = generate_token_id()
|
|
|
|
token = %{
|
|
id: token_id,
|
|
character_id: character_id,
|
|
account_id: account_id,
|
|
source_server: source_server,
|
|
target_channel: target_channel,
|
|
character_data: character_data,
|
|
created_at: System.system_time(:second),
|
|
status: :pending
|
|
}
|
|
|
|
# Store in ETS (local cache)
|
|
:ets.insert(:migration_tokens, {token_id, token})
|
|
|
|
# Also store in Redis for cross-server visibility
|
|
case store_in_redis(token_id, token) do
|
|
:ok ->
|
|
Logger.info("Created migration token #{token_id} for character #{character_id} to channel #{target_channel}")
|
|
{:ok, token_id}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to store migration token in Redis: #{inspect(reason)}")
|
|
# Still return OK since ETS has it (for single-node deployments)
|
|
{:ok, token_id}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates a migration token when a character migrates into a server.
|
|
|
|
Called by InterServerHandler.MigrateIn when player connects to channel/shop.
|
|
|
|
## Parameters
|
|
- token_id: The migration token ID
|
|
- character_id: Expected character ID
|
|
- target_server: :channel or :shop
|
|
|
|
## Returns
|
|
- {:ok, token} if valid
|
|
- {:error, reason} if invalid/expired
|
|
"""
|
|
def validate_migration_token(token_id, character_id, target_server) do
|
|
# Try Redis first (for cross-server)
|
|
token =
|
|
case get_from_redis(token_id) do
|
|
{:ok, nil} ->
|
|
# Try ETS (local fallback)
|
|
get_from_ets(token_id)
|
|
|
|
{:ok, token} ->
|
|
token
|
|
|
|
{:error, _} ->
|
|
get_from_ets(token_id)
|
|
end
|
|
|
|
cond do
|
|
is_nil(token) ->
|
|
Logger.warning("Migration token #{token_id} not found")
|
|
{:error, :token_not_found}
|
|
|
|
token.character_id != character_id ->
|
|
Logger.warning("Migration token character mismatch: expected #{character_id}, got #{token.character_id}")
|
|
{:error, :character_mismatch}
|
|
|
|
token_expired?(token) ->
|
|
Logger.warning("Migration token #{token_id} expired")
|
|
delete_token(token_id)
|
|
{:error, :token_expired}
|
|
|
|
token.status != :pending ->
|
|
Logger.warning("Migration token #{token_id} already used")
|
|
{:error, :token_already_used}
|
|
|
|
true ->
|
|
# Mark as used
|
|
update_token_status(token_id, :used)
|
|
{:ok, token}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets pending character data for a migration.
|
|
Returns nil if no pending migration exists.
|
|
"""
|
|
def get_pending_character(character_id) do
|
|
# Search for pending tokens for this character
|
|
case :ets.select(:migration_tokens, [
|
|
{{:_, %{character_id: character_id, status: :pending}}, [], [:_]}])
|
|
do
|
|
[{_key, token}] -> token
|
|
[] -> nil
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Deletes a migration token.
|
|
"""
|
|
def delete_token(token_id) do
|
|
:ets.delete(:migration_tokens, token_id)
|
|
delete_from_redis(token_id)
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Cleans up expired tokens.
|
|
Should be called periodically.
|
|
"""
|
|
def cleanup_expired_tokens do
|
|
now = System.system_time(:second)
|
|
|
|
expired = :ets.select(:migration_tokens, [
|
|
{{:_, %{created_at: :"$1", id: :"$2"}},
|
|
[{:<, {:+, :"$1", @token_ttl}, now}],
|
|
[:"$2"]}
|
|
])
|
|
|
|
Enum.each(expired, fn token_id ->
|
|
Logger.debug("Cleaning up expired migration token #{token_id}")
|
|
delete_token(token_id)
|
|
end)
|
|
|
|
length(expired)
|
|
end
|
|
|
|
@doc """
|
|
Gets the count of pending migrations (for server capacity checking).
|
|
"""
|
|
def pending_count do
|
|
:ets.info(:migration_tokens, :size)
|
|
end
|
|
|
|
# ==================================================================================================
|
|
# GenServer Callbacks for Token Cleanup
|
|
# ==================================================================================================
|
|
|
|
use GenServer
|
|
|
|
def start_link(_opts) do
|
|
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
|
end
|
|
|
|
@impl true
|
|
def init(_) do
|
|
# Create ETS table for migration tokens
|
|
:ets.new(:migration_tokens, [
|
|
:set,
|
|
:public,
|
|
:named_table,
|
|
read_concurrency: true,
|
|
write_concurrency: true
|
|
])
|
|
|
|
# Schedule cleanup every 30 seconds
|
|
schedule_cleanup()
|
|
|
|
{:ok, %{}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:cleanup, state) do
|
|
count = cleanup_expired_tokens()
|
|
|
|
if count > 0 do
|
|
Logger.debug("Cleaned up #{count} expired migration tokens")
|
|
end
|
|
|
|
schedule_cleanup()
|
|
{:noreply, state}
|
|
end
|
|
|
|
defp schedule_cleanup do
|
|
Process.send_after(self(), :cleanup, 30_000)
|
|
end
|
|
|
|
# ==================================================================================================
|
|
# Private Functions
|
|
# ==================================================================================================
|
|
|
|
defp generate_token_id do
|
|
:crypto.strong_rand_bytes(16)
|
|
|> Base.encode16(case: :lower)
|
|
end
|
|
|
|
defp token_expired?(token) do
|
|
now = System.system_time(:second)
|
|
now > token.created_at + @token_ttl
|
|
end
|
|
|
|
defp update_token_status(token_id, status) do
|
|
case :ets.lookup(:migration_tokens, token_id) do
|
|
[{^token_id, token}] ->
|
|
updated = %{token | status: status}
|
|
:ets.insert(:migration_tokens, {token_id, updated})
|
|
store_in_redis(token_id, updated)
|
|
|
|
[] ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp get_from_ets(token_id) do
|
|
case :ets.lookup(:migration_tokens, token_id) do
|
|
[{^token_id, token}] -> token
|
|
[] -> nil
|
|
end
|
|
end
|
|
|
|
# Redis operations (for cross-server communication)
|
|
|
|
defp store_in_redis(token_id, token) do
|
|
case Redix.command(:redix, ["SETEX", "migration:#{token_id}", @token_ttl, :erlang.term_to_binary(token)]) do
|
|
{:ok, _} -> :ok
|
|
error -> error
|
|
end
|
|
rescue
|
|
_ -> {:error, :redis_unavailable}
|
|
end
|
|
|
|
defp get_from_redis(token_id) do
|
|
case Redix.command(:redix, ["GET", "migration:#{token_id}"]) do
|
|
{:ok, nil} -> {:ok, nil}
|
|
{:ok, data} -> {:ok, :erlang.binary_to_term(data)}
|
|
error -> error
|
|
end
|
|
rescue
|
|
_ -> {:error, :redis_unavailable}
|
|
end
|
|
|
|
defp delete_from_redis(token_id) do
|
|
case Redix.command(:redix, ["DEL", "migration:#{token_id}"]) do
|
|
{:ok, _} -> :ok
|
|
error -> error
|
|
end
|
|
rescue
|
|
_ -> {:ok}
|
|
end
|
|
end
|