257 lines
7.3 KiB
Elixir
257 lines
7.3 KiB
Elixir
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
|
|
|
|
<data_dir>/world/pokemon/playerpartystore/<uuid>.dat
|
|
<data_dir>/world/pokemon/pcstore/<uuid>.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
|