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