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