defmodule CobblemonUi.CobblemonFS do @moduledoc """ GenServer that reads Cobblemon player data from the filesystem. Provides cached access to player party and PC storage data parsed from NBT `.dat` files under the configured data directory. ## Configuration config :cobblemon_ui, CobblemonUi.CobblemonFS, data_dir: "/cobblemon-data", cache_ttl_ms: 2_000 ## Data directory structure /world/pokemon/playerpartystore/.dat /world/pokemon/pcstore/.dat """ use GenServer alias CobblemonUi.CobblemonFS.{PartyStore, PCStore} @default_data_dir "/cobblemon-data" @default_cache_ttl_ms 2_000 @uuid_regex ~r/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ # --- Client API --- def start_link(opts \\ []) do name = Keyword.get(opts, :name, __MODULE__) GenServer.start_link(__MODULE__, opts, name: name) end @doc "Returns full player data: party + PC." @spec get_player(String.t()) :: {:ok, map()} | {:error, term()} def get_player(uuid), do: call({:get_player, uuid}) @doc "Returns only the player's party Pokémon." @spec get_party(String.t()) :: {:ok, list()} | {:error, term()} def get_party(uuid), do: call({:get_party, uuid}) @doc "Returns only the player's PC boxes." @spec get_pc(String.t()) :: {:ok, list()} | {:error, term()} def get_pc(uuid), do: call({:get_pc, uuid}) @doc "Returns a specific Pokémon by index (0-5 = party, 6+ = PC)." @spec get_pokemon(String.t(), non_neg_integer()) :: {:ok, map() | nil} | {:error, term()} def get_pokemon(uuid, index), do: call({:get_pokemon, uuid, index}) @doc "Lists all players as `%{uuid: String.t(), name: String.t() | nil}` maps." @spec list_players() :: {:ok, list(map())} def list_players, do: GenServer.call(__MODULE__, :list_players) defp call(msg), do: GenServer.call(__MODULE__, msg) # --- Server Callbacks --- @impl true def init(opts) do config = Application.get_env(:cobblemon_ui, __MODULE__, []) data_dir = Keyword.get(config, :data_dir, Keyword.get(opts, :data_dir, @default_data_dir)) cache_ttl = Keyword.get(config, :cache_ttl_ms, @default_cache_ttl_ms) {:ok, %{ data_dir: data_dir, cache_ttl: cache_ttl, cache: %{} }} end @impl true def handle_call({:get_player, uuid}, _from, state) do with :ok <- validate_uuid(uuid), {player_data, state} <- fetch_player(uuid, state) do {:reply, {:ok, player_data}, state} else {:error, _} = err -> {:reply, err, state} end end def handle_call({:get_party, uuid}, _from, state) do with :ok <- validate_uuid(uuid), {player_data, state} <- fetch_player(uuid, state) do {:reply, {:ok, player_data.party}, state} else {:error, _} = err -> {:reply, err, state} end end def handle_call({:get_pc, uuid}, _from, state) do with :ok <- validate_uuid(uuid), {player_data, state} <- fetch_player(uuid, state) do {:reply, {:ok, player_data.pc}, state} else {:error, _} = err -> {:reply, err, state} end end def handle_call({:get_pokemon, uuid, index}, _from, state) do with :ok <- validate_uuid(uuid), {player_data, state} <- fetch_player(uuid, state) do pokemon = find_pokemon(player_data, index) {:reply, {:ok, pokemon}, state} else {:error, _} = err -> {:reply, err, state} end end def handle_call(:list_players, _from, state) do party_dir = Path.join([state.data_dir, "world", "pokemon", "playerpartystore"]) pc_dir = Path.join([state.data_dir, "world", "pokemon", "pcstore"]) usercache = load_usercache(state.data_dir) uuids = [party_dir, pc_dir] |> Enum.flat_map(&list_uuids_in_dir/1) |> Enum.uniq() |> Enum.sort() players = Enum.map(uuids, fn uuid -> %{uuid: uuid, name: Map.get(usercache, uuid)} end) {:reply, {:ok, players}, state} end # --- Private Helpers --- defp validate_uuid(uuid) do if Regex.match?(@uuid_regex, uuid), do: :ok, else: {:error, :invalid_uuid} end defp fetch_player(uuid, state) do now = System.monotonic_time(:millisecond) case Map.get(state.cache, uuid) do {data, expires_at} when expires_at > now -> {data, state} _ -> case load_player(uuid, state.data_dir) do {:ok, data} -> expires_at = now + state.cache_ttl cache = Map.put(state.cache, uuid, {data, expires_at}) {data, %{state | cache: cache}} {:error, _} = err -> err end end end defp uuid_prefix(uuid), do: String.slice(uuid, 0, 2) defp load_player(uuid, data_dir) do prefix = uuid_prefix(uuid) party_path = Path.join([data_dir, "world", "pokemon", "playerpartystore", prefix, "#{uuid}.dat"]) pc_path = Path.join([data_dir, "world", "pokemon", "pcstore", prefix, "#{uuid}.dat"]) party_exists? = File.exists?(party_path) pc_exists? = File.exists?(pc_path) if not party_exists? and not pc_exists? do {:error, :not_found} else party_result = if party_exists?, do: PartyStore.parse(party_path), else: {:ok, []} pc_result = if pc_exists?, do: PCStore.parse(pc_path), else: {:ok, []} case {party_result, pc_result} do {{:ok, party}, {:ok, pc}} -> {:ok, %{uuid: uuid, party: party, pc: pc}} {{:error, reason}, _} -> {:error, reason} {_, {:error, reason}} -> {:error, reason} end end end defp find_pokemon(%{party: party, pc: pc}, index) when is_integer(index) do party_size = length(party) if index < party_size do Enum.at(party, index) else pc_index = index - party_size all_pc_pokemon = Enum.flat_map(pc, fn box -> Map.get(box, :pokemon, []) end) Enum.at(all_pc_pokemon, pc_index) end end defp list_uuids_in_dir(dir) do case File.ls(dir) do {:ok, entries} -> entries |> Enum.flat_map(fn entry -> subdir = Path.join(dir, entry) case File.dir?(subdir) do true -> case File.ls(subdir) do {:ok, files} -> files |> Enum.filter(&String.ends_with?(&1, ".dat")) |> Enum.reject(&String.ends_with?(&1, ".dat.old")) |> Enum.map(&String.trim_trailing(&1, ".dat")) |> Enum.filter(&Regex.match?(@uuid_regex, &1)) {:error, _} -> [] end false -> if String.ends_with?(entry, ".dat") and not String.ends_with?(entry, ".dat.old") do uuid = String.trim_trailing(entry, ".dat") if Regex.match?(@uuid_regex, uuid), do: [uuid], else: [] else [] end end end) {:error, _} -> [] end end defp load_usercache(data_dir) do path = Path.join(data_dir, "usercache.json") case File.read(path) do {:ok, data} -> case Jason.decode(data) do {:ok, entries} when is_list(entries) -> Map.new(entries, fn entry -> {Map.get(entry, "uuid", ""), Map.get(entry, "name")} end) _ -> %{} end {:error, _} -> %{} end end end