diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ead753e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +.cobblemon-data/ + + +# Git +.git +.gitignore + +# Mix artifacts +/_build +/deps +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts +/priv/static + +# Since we are building assets from assets/, +# we ignore priv/static. You may want to comment +# this depending on your deployment strategy. +/priv/static/ + +# Ignore package manager lock files +npm-debug.log +yarn-error.log + +# Editor and IDE files +.elixir_ls +.vscode +.idea + +# OS files +.DS_Store +Thumbs.db + +# Docker +docker-compose.yml +Dockerfile +.dockerignore + +# Environment variables +.env +.env.* diff --git a/.gitignore b/.gitignore index e65e625..f56dcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.cobblemon-data/ .elixir_ls/ # The directory Mix will write compiled artifacts to. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6da12c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM hexpm/elixir:1.18.4-erlang-26.2.5.18-debian-bookworm-20260223-slim + +RUN apt-get update && apt-get install -y \ + build-essential \ + git \ + curl \ + inotify-tools \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +RUN mix local.hex --force && \ + mix local.rebar --force + +COPY mix.exs mix.lock ./ +RUN mix deps.get + +COPY config config +COPY assets assets +COPY lib lib +COPY priv priv + +RUN mix assets.setup + +EXPOSE 4000 + +CMD ["mix", "phx.server"] diff --git a/config/config.exs b/config/config.exs index af3b7cd..77e3730 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,7 +21,6 @@ config :cobblemon_ui, CobblemonUiWeb.Endpoint, pubsub_server: CobblemonUi.PubSub, live_view: [signing_salt: "X61XE3GM"] - # Configure esbuild (the version is required) config :esbuild, version: "0.25.4", diff --git a/config/dev.exs b/config/dev.exs index 479943a..cb28d85 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -9,7 +9,7 @@ import Config config :cobblemon_ui, CobblemonUiWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}], + http: [ip: {0, 0, 0, 0}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, diff --git a/config/runtime.exs b/config/runtime.exs index 734096a..cb68cb1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -82,5 +82,4 @@ if config_env() == :prod do # force_ssl: [hsts: true] # # Check `Plug.SSL` for all available options in `force_ssl`. - end diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..f80b20b --- /dev/null +++ b/dev.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -e + +REMOTE_USER="alex" +REMOTE_HOST="server" +REMOTE_PATH="/data/minecraft/cobblemon-data" +LOCAL_MOUNT="./.cobblemon-data" + +# Cleanup function to unmount +cleanup() { + echo "" + echo "Unmounting SSHFS..." + fusermount -u "$LOCAL_MOUNT" 2>/dev/null || umount "$LOCAL_MOUNT" 2>/dev/null || true + echo "Done!" +} + +# Set trap to cleanup on script exit +trap cleanup EXIT INT TERM + +echo "Preparing mount directory..." +mkdir -p "$LOCAL_MOUNT" + +echo "Mounting Cobblemon server via SSHFS..." +sshfs -o allow_other "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}" "$LOCAL_MOUNT" + +echo "" +echo "Cobblemon data mounted at: $LOCAL_MOUNT" +echo "Press Enter to unmount..." +read -r \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..159cc4d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + web: + build: . + ports: + - "4000:4000" + environment: + - MIX_ENV=dev + - PHX_HOST=localhost + volumes: + - .:/app + - deps:/app/deps + - build:/app/_build + - type: bind + source: ./.cobblemon-data + target: /cobblemon-data + bind: + create_host_path: false + command: sh -c "mix deps.get && mix assets.setup && mix phx.server" + +volumes: + deps: + build: diff --git a/lib/cobblemon_ui/application.ex b/lib/cobblemon_ui/application.ex index 41fc0a9..1364c92 100644 --- a/lib/cobblemon_ui/application.ex +++ b/lib/cobblemon_ui/application.ex @@ -11,8 +11,7 @@ defmodule CobblemonUi.Application do CobblemonUiWeb.Telemetry, {DNSCluster, query: Application.get_env(:cobblemon_ui, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: CobblemonUi.PubSub}, - # Start a worker by calling: CobblemonUi.Worker.start_link(arg) - # {CobblemonUi.Worker, arg}, + CobblemonUi.CobblemonFS, # Start to serve requests, typically the last entry CobblemonUiWeb.Endpoint ] diff --git a/lib/cobblemon_ui/cobblemon_fs/cobblemon_fs.ex b/lib/cobblemon_ui/cobblemon_fs/cobblemon_fs.ex new file mode 100644 index 0000000..ef0f509 --- /dev/null +++ b/lib/cobblemon_ui/cobblemon_fs/cobblemon_fs.ex @@ -0,0 +1,256 @@ +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 diff --git a/lib/cobblemon_ui/cobblemon_fs/nbt.ex b/lib/cobblemon_ui/cobblemon_fs/nbt.ex new file mode 100644 index 0000000..54d7baf --- /dev/null +++ b/lib/cobblemon_ui/cobblemon_fs/nbt.ex @@ -0,0 +1,135 @@ +defmodule CobblemonUi.CobblemonFS.NBT do + @moduledoc """ + Decoder for Minecraft's Named Binary Tag (NBT) format. + + Supports both gzip-compressed and uncompressed NBT files. + All multi-byte integers are big-endian and signed unless noted. + """ + + # NBT tag type constants + @tag_end 0 + @tag_byte 1 + @tag_short 2 + @tag_int 3 + @tag_long 4 + @tag_float 5 + @tag_double 6 + @tag_byte_array 7 + @tag_string 8 + @tag_list 9 + @tag_compound 10 + @tag_int_array 11 + @tag_long_array 12 + + @doc """ + Decodes an NBT binary (possibly gzip-compressed) into an Elixir map. + + Returns `{:ok, {name, value}}` or `{:error, reason}`. + """ + @spec decode(binary()) :: {:ok, {String.t(), map()}} | {:error, term()} + def decode(data) when is_binary(data) do + data = maybe_decompress(data) + + case decode_named_tag(data) do + {:ok, {name, value}, _rest} -> {:ok, {name, value}} + {:error, _} = err -> err + end + rescue + e -> {:error, {:decode_failed, Exception.message(e)}} + end + + defp maybe_decompress(<<0x1F, 0x8B, _rest::binary>> = data), do: :zlib.gunzip(data) + defp maybe_decompress(data), do: data + + # Decodes a single named tag: type byte + name + payload + defp decode_named_tag(<>) do + with {:ok, name, rest} <- decode_string_payload(rest), + {:ok, value, rest} <- decode_payload(type, rest) do + {:ok, {name, value}, rest} + end + end + + defp decode_named_tag(_), do: {:error, :unexpected_eof} + + # String payload: 2-byte unsigned length prefix + UTF-8 bytes + defp decode_string_payload(<>) do + {:ok, str, rest} + end + + defp decode_string_payload(_), do: {:error, :invalid_string} + + # --- Payload decoders by tag type --- + + defp decode_payload(@tag_byte, <>), do: {:ok, val, rest} + defp decode_payload(@tag_short, <>), do: {:ok, val, rest} + defp decode_payload(@tag_int, <>), do: {:ok, val, rest} + defp decode_payload(@tag_long, <>), do: {:ok, val, rest} + defp decode_payload(@tag_float, <>), do: {:ok, val, rest} + defp decode_payload(@tag_double, <>), do: {:ok, val, rest} + + defp decode_payload( + @tag_byte_array, + <> + ) do + {:ok, :binary.bin_to_list(bytes), rest} + end + + defp decode_payload(@tag_string, data), do: decode_string_payload(data) |> wrap_string_result() + + defp decode_payload(@tag_list, <>) do + decode_list_items(elem_type, len, rest, []) + end + + defp decode_payload(@tag_compound, data), do: decode_compound(data, %{}) + + defp decode_payload(@tag_int_array, <>) do + decode_int_array(len, rest, []) + end + + defp decode_payload(@tag_long_array, <>) do + decode_long_array(len, rest, []) + end + + defp decode_payload(type, _), do: {:error, {:unsupported_tag_type, type}} + + # --- Compound decoder --- + + defp decode_compound(<<@tag_end, rest::binary>>, acc), do: {:ok, acc, rest} + + defp decode_compound(<>, acc) do + with {:ok, name, rest} <- decode_string_payload(rest), + {:ok, value, rest} <- decode_payload(type, rest) do + decode_compound(rest, Map.put(acc, name, value)) + end + end + + defp decode_compound(<<>>, _acc), do: {:error, :unexpected_eof_in_compound} + + # --- List decoder --- + + defp decode_list_items(_type, 0, rest, acc), do: {:ok, Enum.reverse(acc), rest} + + defp decode_list_items(type, n, rest, acc) when n > 0 do + with {:ok, value, rest} <- decode_payload(type, rest) do + decode_list_items(type, n - 1, rest, [value | acc]) + end + end + + # --- Array decoders --- + + defp decode_int_array(0, rest, acc), do: {:ok, Enum.reverse(acc), rest} + + defp decode_int_array(n, <>, acc) do + decode_int_array(n - 1, rest, [val | acc]) + end + + defp decode_long_array(0, rest, acc), do: {:ok, Enum.reverse(acc), rest} + + defp decode_long_array(n, <>, acc) do + decode_long_array(n - 1, rest, [val | acc]) + end + + # Helper to wrap string decode result to match expected return shape + defp wrap_string_result({:ok, str, rest}), do: {:ok, str, rest} + defp wrap_string_result(err), do: err +end diff --git a/lib/cobblemon_ui/cobblemon_fs/party_store.ex b/lib/cobblemon_ui/cobblemon_fs/party_store.ex new file mode 100644 index 0000000..b984cf8 --- /dev/null +++ b/lib/cobblemon_ui/cobblemon_fs/party_store.ex @@ -0,0 +1,30 @@ +defmodule CobblemonUi.CobblemonFS.PartyStore do + @moduledoc """ + Parses a player's party storage `.dat` file. + + Party layout: + Root -> party -> slot0..slot5 + """ + + alias CobblemonUi.CobblemonFS.{NBT, Pokemon} + + @party_slots 6 + + @doc """ + Reads and parses a party `.dat` file at the given path. + + Returns `{:ok, [pokemon | nil]}` or `{:error, reason}`. + """ + @spec parse(String.t()) :: {:ok, list(map() | nil)} | {:error, term()} + def parse(path) do + with {:ok, data} <- File.read(path), + {:ok, {_name, root}} <- NBT.decode(data) do + party = Map.get(root, "party", %{}) + slots = for i <- 0..(@party_slots - 1), do: Pokemon.normalize(Map.get(party, "slot#{i}")) + {:ok, slots} + else + {:error, :enoent} -> {:error, :not_found} + {:error, reason} -> {:error, {:corrupt_data, reason}} + end + end +end diff --git a/lib/cobblemon_ui/cobblemon_fs/pc_store.ex b/lib/cobblemon_ui/cobblemon_fs/pc_store.ex new file mode 100644 index 0000000..1b1768d --- /dev/null +++ b/lib/cobblemon_ui/cobblemon_fs/pc_store.ex @@ -0,0 +1,57 @@ +defmodule CobblemonUi.CobblemonFS.PCStore do + @moduledoc """ + Parses a player's PC storage `.dat` file. + + PC layout: + Root -> boxes -> box0..boxN -> slot0..slotN + """ + + alias CobblemonUi.CobblemonFS.{NBT, Pokemon} + + @doc """ + Reads and parses a PC storage `.dat` file at the given path. + + Returns `{:ok, [%{box: integer, pokemon: list}]}` or `{:error, reason}`. + """ + @spec parse(String.t()) :: {:ok, list(map())} | {:error, term()} + def parse(path) do + with {:ok, data} <- File.read(path), + {:ok, {_name, root}} <- NBT.decode(data) do + boxes = Map.get(root, "boxes", %{}) + {:ok, normalize_boxes(boxes)} + else + {:error, :enoent} -> {:error, :not_found} + {:error, reason} -> {:error, {:corrupt_data, reason}} + end + end + + defp normalize_boxes(boxes) when is_map(boxes) do + boxes + |> Enum.filter(fn {key, _} -> String.starts_with?(key, "box") end) + |> Enum.sort_by(fn {key, _} -> extract_index(key) end) + |> Enum.map(fn {key, box_data} -> + %{ + box: extract_index(key), + pokemon: normalize_box_slots(box_data) + } + end) + end + + defp normalize_boxes(_), do: [] + + defp normalize_box_slots(box) when is_map(box) do + box + |> Enum.filter(fn {key, _} -> String.starts_with?(key, "slot") end) + |> Enum.sort_by(fn {key, _} -> extract_index(key) end) + |> Enum.map(fn {_key, slot_data} -> Pokemon.normalize(slot_data) end) + end + + defp normalize_box_slots(_), do: [] + + defp extract_index(key) do + case Integer.parse(String.replace(key, ~r/[^\d]/, "")) do + {n, _} -> n + :error -> 0 + end + end +end diff --git a/lib/cobblemon_ui/cobblemon_fs/pokemon.ex b/lib/cobblemon_ui/cobblemon_fs/pokemon.ex new file mode 100644 index 0000000..fda47e3 --- /dev/null +++ b/lib/cobblemon_ui/cobblemon_fs/pokemon.ex @@ -0,0 +1,76 @@ +defmodule CobblemonUi.CobblemonFS.Pokemon do + @moduledoc """ + Normalizes raw NBT Pokémon compound data into a structured map. + """ + + @stat_keys ~w(hp attack defense special_attack special_defense speed) + + @doc """ + Normalizes a raw NBT compound map representing a single Pokémon + into the standardized format. + + Returns `nil` if the input is `nil` (empty slot). + """ + @spec normalize(map() | nil) :: map() | nil + def normalize(nil), do: nil + + def normalize(raw) when is_map(raw) do + %{ + species: get_string(raw, "species"), + level: get_int(raw, "level"), + form: get_string(raw, "form", "default"), + shiny: get_boolean(raw, "shiny"), + nature: get_string(raw, "nature"), + gender: get_string(raw, "gender"), + experience: get_int(raw, "experience"), + friendship: get_int(raw, "friendship"), + ability: get_string(raw, "ability"), + ivs: normalize_stats(Map.get(raw, "ivs")), + evs: normalize_stats(Map.get(raw, "evs")), + moves: normalize_moves(Map.get(raw, "moves")) + } + end + + defp normalize_stats(nil), do: nil + + defp normalize_stats(stats) when is_map(stats) do + Map.new(@stat_keys, fn key -> {String.to_atom(key), get_int(stats, key, 0)} end) + end + + defp normalize_moves(nil), do: [] + + defp normalize_moves(moves) when is_list(moves) do + Enum.map(moves, fn + move when is_map(move) -> get_string(move, "id") + move when is_binary(move) -> move + _ -> nil + end) + |> Enum.reject(&is_nil/1) + end + + defp normalize_moves(_), do: [] + + defp get_string(map, key, default \\ nil) do + case Map.get(map, key) do + val when is_binary(val) -> val + _ -> default + end + end + + defp get_int(map, key, default \\ nil) do + case Map.get(map, key) do + val when is_integer(val) -> val + _ -> default + end + end + + defp get_boolean(map, key) do + case Map.get(map, key) do + 1 -> true + 0 -> false + true -> true + false -> false + _ -> false + end + end +end diff --git a/lib/cobblemon_ui_web/components/layouts.ex b/lib/cobblemon_ui_web/components/layouts.ex index bdd6354..c496981 100644 --- a/lib/cobblemon_ui_web/components/layouts.ex +++ b/lib/cobblemon_ui_web/components/layouts.ex @@ -35,37 +35,8 @@ defmodule CobblemonUiWeb.Layouts do def app(assigns) do ~H""" - - -
-
- {render_slot(@inner_block)} -
+
+ {render_slot(@inner_block)}
<.flash_group flash={@flash} /> diff --git a/lib/cobblemon_ui_web/components/layouts/root.html.heex b/lib/cobblemon_ui_web/components/layouts/root.html.heex index 3d1160f..176f74f 100644 --- a/lib/cobblemon_ui_web/components/layouts/root.html.heex +++ b/lib/cobblemon_ui_web/components/layouts/root.html.heex @@ -1,5 +1,5 @@ - + diff --git a/lib/cobblemon_ui_web/live/dashboard_live.ex b/lib/cobblemon_ui_web/live/dashboard_live.ex new file mode 100644 index 0000000..1f7e85c --- /dev/null +++ b/lib/cobblemon_ui_web/live/dashboard_live.ex @@ -0,0 +1,596 @@ +defmodule CobblemonUiWeb.DashboardLive do + use CobblemonUiWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + players = + case CobblemonUi.CobblemonFS.list_players() do + {:ok, list} -> list + _ -> [] + end + + {:ok, + assign(socket, + page_title: "Cobblemon Dashboard", + players: players, + selected_player: nil, + player_data: nil, + selected_pokemon: nil, + view_mode: :party, + loading: false, + error: nil + )} + end + + @impl true + def handle_params(%{"uuid" => uuid}, _uri, socket) do + case CobblemonUi.CobblemonFS.get_player(uuid) do + {:ok, data} -> + {:noreply, + assign(socket, + selected_player: uuid, + player_data: data, + selected_pokemon: nil, + error: nil + )} + + {:error, :not_found} -> + {:noreply, + assign(socket, + selected_player: uuid, + player_data: nil, + error: "Player not found" + )} + + {:error, reason} -> + {:noreply, + assign(socket, + selected_player: uuid, + player_data: nil, + error: "Error loading player: #{inspect(reason)}" + )} + end + end + + def handle_params(_params, _uri, socket) do + {:noreply, assign(socket, selected_player: nil, player_data: nil, selected_pokemon: nil)} + end + + @impl true + def handle_event("select_pokemon", %{"index" => index_str}, socket) do + index = String.to_integer(index_str) + + pokemon = + case socket.assigns.view_mode do + :party -> + Enum.at(socket.assigns.player_data.party, index) + + :pc -> + socket.assigns.player_data.pc + |> Enum.flat_map(fn box -> box.pokemon end) + |> Enum.at(index) + end + + {:noreply, assign(socket, selected_pokemon: pokemon)} + end + + def handle_event("close_pokemon", _params, socket) do + {:noreply, assign(socket, selected_pokemon: nil)} + end + + def handle_event("switch_view", %{"mode" => mode}, socket) do + {:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)} + end + + def handle_event("refresh", _params, socket) do + players = + case CobblemonUi.CobblemonFS.list_players() do + {:ok, list} -> list + _ -> [] + end + + socket = + if uuid = socket.assigns.selected_player do + case CobblemonUi.CobblemonFS.get_player(uuid) do + {:ok, data} -> assign(socket, player_data: data, error: nil) + _ -> socket + end + else + socket + end + + {:noreply, assign(socket, players: players)} + end + + @impl true + def render(assigns) do + ~H""" + +
+
+ <%!-- Header --%> +
+
+
+ <.icon name="hero-cube-transparent" class="size-6 text-primary" /> +
+
+

Cobblemon

+

Player Data Explorer

+
+
+ +
+ +
+ <%!-- Sidebar: Player List --%> + + + <%!-- Main Content --%> +
+
+
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@error} +
+
+ + <%!-- Empty state --%> +
+ <.icon + name="hero-arrow-left" + class="size-10 mx-auto mb-4 text-base-content/20" + /> +

Select a player to explore

+

+ Choose from the sidebar to view their Pokémon +

+
+ + <%!-- Player data --%> +
+ <%!-- Player header --%> +
+
+
+ <.icon name="hero-user" class="size-5 text-primary/70" /> +
+
+

+ {player_name(@players, @player_data.uuid)} +

+

+ {@player_data.uuid} +

+
+
+
+
+ {party_count(@player_data)} in party +
+
+
+ {pc_count(@player_data)} in PC +
+
+
+
+ + <%!-- View mode tabs --%> +
+ + +
+ + <%!-- Party view --%> +
+
+ <%= for {pokemon, idx} <- Enum.with_index(@player_data.party) do %> + <.pokemon_card pokemon={pokemon} index={idx} /> + <% end %> +
+
+ + <%!-- PC view --%> +
+
+

+ Box {box.box + 1} +

+
+ <%= for {pokemon, idx} <- Enum.with_index(box.pokemon) do %> + <.pokemon_card + pokemon={pokemon} + index={pc_global_index(@player_data.pc, box.box, idx)} + compact + /> + <% end %> +
+
+
+ PC storage is empty +
+
+ + <%!-- Pokémon detail panel --%> + <.pokemon_detail :if={@selected_pokemon} pokemon={@selected_pokemon} /> +
+
+
+
+
+
+ """ + end + + # --- Components --- + + attr :pokemon, :map, required: true + attr :index, :integer, required: true + attr :compact, :boolean, default: false + + defp pokemon_card(%{pokemon: nil} = assigns) do + ~H""" +
+ Empty +
+ """ + end + + defp pokemon_card(assigns) do + ~H""" + + """ + end + + attr :pokemon, :map, required: true + + defp pokemon_detail(assigns) do + ~H""" +
+ <%!-- Detail header --%> +
+
+
+ <.icon name="hero-bolt" class="size-4 text-primary/70" /> +
+
+

+ {@pokemon.species || "Unknown"} + + ★ + +

+

+ Level {@pokemon.level || "?"} · {String.capitalize(@pokemon.form || "default")} form +

+
+
+ +
+ +
+ <%!-- Info Column --%> +
+
+

+ Details +

+
+ <.stat_pill label="Nature" value={@pokemon.nature} /> + <.stat_pill label="Ability" value={@pokemon.ability} /> + <.stat_pill label="Gender" value={@pokemon.gender} /> + <.stat_pill label="Friendship" value={@pokemon.friendship} /> +
+
+ + <%!-- Moves --%> +
+

+ Moves +

+
+
+ {format_move(move)} +
+
+ No moves +
+
+
+
+ + <%!-- Stats Column --%> +
+ <%!-- IVs --%> +
+

+ IVs +

+
+ <.stat_bar + :for={{stat, val} <- stat_list(@pokemon.ivs)} + label={format_stat(stat)} + value={val} + max={31} + /> +
+
+ + <%!-- EVs --%> +
+

+ EVs + + ({ev_total(@pokemon.evs)}/510) + +

+
+ <.stat_bar + :for={{stat, val} <- stat_list(@pokemon.evs)} + label={format_stat(stat)} + value={val} + max={252} + /> +
+
+
+
+
+ """ + end + + attr :label, :string, required: true + attr :value, :any, required: true + + defp stat_pill(assigns) do + ~H""" +
+

{@label}

+

{@value || "—"}

+
+ """ + end + + attr :label, :string, required: true + attr :value, :integer, required: true + attr :max, :integer, required: true + + defp stat_bar(assigns) do + pct = if assigns.max > 0, do: min(assigns.value / assigns.max * 100, 100), else: 0 + + color = + cond do + pct >= 90 -> "bg-success" + pct >= 60 -> "bg-info" + pct >= 30 -> "bg-warning" + true -> "bg-error/70" + end + + assigns = assign(assigns, pct: pct, color: color) + + ~H""" +
+ + {@label} + +
+
+
+ + {@value} + +
+ """ + end + + # --- Helpers --- + + defp party_count(%{party: party}), do: Enum.count(party, &(not is_nil(&1))) + + defp pc_count(%{pc: pc}), + do: Enum.sum(Enum.map(pc, fn b -> Enum.count(b.pokemon, &(not is_nil(&1))) end)) + + defp pc_global_index(boxes, current_box, slot_index) do + offset = + boxes + |> Enum.filter(fn b -> b.box < current_box end) + |> Enum.sum_by(fn b -> length(b.pokemon) end) + + offset + slot_index + end + + defp gender_symbol("male"), do: "♂" + defp gender_symbol("female"), do: "♀" + defp gender_symbol(_), do: "—" + + defp gender_color("male"), do: "text-info" + defp gender_color("female"), do: "text-error" + defp gender_color(_), do: "text-base-content/40" + + defp format_move(move) when is_binary(move), do: String.replace(move, "_", " ") + defp format_move(_), do: "—" + + defp format_stat(:hp), do: "HP" + defp format_stat(:attack), do: "ATK" + defp format_stat(:defense), do: "DEF" + defp format_stat(:special_attack), do: "SPA" + defp format_stat(:special_defense), do: "SPD" + defp format_stat(:speed), do: "SPE" + defp format_stat(other), do: to_string(other) + + defp stat_list(stats) when is_map(stats) do + [:hp, :attack, :defense, :special_attack, :special_defense, :speed] + |> Enum.map(fn key -> {key, Map.get(stats, key, 0)} end) + end + + defp stat_list(_), do: [] + + defp ev_total(evs) when is_map(evs) do + evs |> Map.values() |> Enum.sum() + end + + defp ev_total(_), do: 0 + + defp player_name(players, uuid) do + case Enum.find(players, fn p -> p.uuid == uuid end) do + %{name: name} when is_binary(name) -> name + _ -> "Unknown" + end + end +end diff --git a/lib/cobblemon_ui_web/router.ex b/lib/cobblemon_ui_web/router.ex index 4b6727d..46fd757 100644 --- a/lib/cobblemon_ui_web/router.ex +++ b/lib/cobblemon_ui_web/router.ex @@ -17,7 +17,8 @@ defmodule CobblemonUiWeb.Router do scope "/", CobblemonUiWeb do pipe_through :browser - get "/", PageController, :home + live "/", DashboardLive + live "/player/:uuid", DashboardLive end # Other scopes may use custom stacks. diff --git a/mix.lock b/mix.lock index 259ffaa..a490221 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,6 @@ "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, @@ -28,13 +27,11 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } diff --git a/test/cobblemon_ui_web/controllers/error_html_test.exs b/test/cobblemon_ui_web/controllers/error_html_test.exs index 3af5e70..31f4d42 100644 --- a/test/cobblemon_ui_web/controllers/error_html_test.exs +++ b/test/cobblemon_ui_web/controllers/error_html_test.exs @@ -9,6 +9,7 @@ defmodule CobblemonUiWeb.ErrorHTMLTest do end test "renders 500.html" do - assert render_to_string(CobblemonUiWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + assert render_to_string(CobblemonUiWeb.ErrorHTML, "500", "html", []) == + "Internal Server Error" end end diff --git a/test/cobblemon_ui_web/controllers/page_controller_test.exs b/test/cobblemon_ui_web/controllers/page_controller_test.exs index 6a93194..c39e493 100644 --- a/test/cobblemon_ui_web/controllers/page_controller_test.exs +++ b/test/cobblemon_ui_web/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule CobblemonUiWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert html_response(conn, 200) =~ "Cobblemon" end end