basics being displayed
This commit is contained in:
44
.dockerignore
Normal file
44
.dockerignore
Normal file
@@ -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.*
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.cobblemon-data/
|
||||
.elixir_ls/
|
||||
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,5 +82,4 @@ if config_env() == :prod do
|
||||
# force_ssl: [hsts: true]
|
||||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
end
|
||||
|
||||
29
dev.sh
Executable file
29
dev.sh
Executable file
@@ -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
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -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:
|
||||
@@ -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
|
||||
]
|
||||
|
||||
256
lib/cobblemon_ui/cobblemon_fs/cobblemon_fs.ex
Normal file
256
lib/cobblemon_ui/cobblemon_fs/cobblemon_fs.ex
Normal file
@@ -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
|
||||
|
||||
<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
|
||||
135
lib/cobblemon_ui/cobblemon_fs/nbt.ex
Normal file
135
lib/cobblemon_ui/cobblemon_fs/nbt.ex
Normal file
@@ -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(<<type, rest::binary>>) 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(<<len::unsigned-big-16, str::binary-size(len), rest::binary>>) do
|
||||
{:ok, str, rest}
|
||||
end
|
||||
|
||||
defp decode_string_payload(_), do: {:error, :invalid_string}
|
||||
|
||||
# --- Payload decoders by tag type ---
|
||||
|
||||
defp decode_payload(@tag_byte, <<val::signed-8, rest::binary>>), do: {:ok, val, rest}
|
||||
defp decode_payload(@tag_short, <<val::signed-big-16, rest::binary>>), do: {:ok, val, rest}
|
||||
defp decode_payload(@tag_int, <<val::signed-big-32, rest::binary>>), do: {:ok, val, rest}
|
||||
defp decode_payload(@tag_long, <<val::signed-big-64, rest::binary>>), do: {:ok, val, rest}
|
||||
defp decode_payload(@tag_float, <<val::float-big-32, rest::binary>>), do: {:ok, val, rest}
|
||||
defp decode_payload(@tag_double, <<val::float-big-64, rest::binary>>), do: {:ok, val, rest}
|
||||
|
||||
defp decode_payload(
|
||||
@tag_byte_array,
|
||||
<<len::signed-big-32, bytes::binary-size(len), rest::binary>>
|
||||
) 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, <<elem_type::8, len::signed-big-32, rest::binary>>) 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, <<len::signed-big-32, rest::binary>>) do
|
||||
decode_int_array(len, rest, [])
|
||||
end
|
||||
|
||||
defp decode_payload(@tag_long_array, <<len::signed-big-32, rest::binary>>) 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(<<type, rest::binary>>, 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, <<val::signed-big-32, rest::binary>>, 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, <<val::signed-big-64, rest::binary>>, 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
|
||||
30
lib/cobblemon_ui/cobblemon_fs/party_store.ex
Normal file
30
lib/cobblemon_ui/cobblemon_fs/party_store.ex
Normal file
@@ -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
|
||||
57
lib/cobblemon_ui/cobblemon_fs/pc_store.ex
Normal file
57
lib/cobblemon_ui/cobblemon_fs/pc_store.ex
Normal file
@@ -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
|
||||
76
lib/cobblemon_ui/cobblemon_fs/pokemon.ex
Normal file
76
lib/cobblemon_ui/cobblemon_fs/pokemon.ex
Normal file
@@ -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
|
||||
@@ -35,37 +35,8 @@ defmodule CobblemonUiWeb.Layouts do
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||
<li>
|
||||
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<.theme_toggle />
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
<main class="min-h-screen bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
596
lib/cobblemon_ui_web/live/dashboard_live.ex
Normal file
596
lib/cobblemon_ui_web/live/dashboard_live.ex
Normal file
@@ -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"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<div class="min-h-screen -mx-4 -my-20 sm:-mx-6 lg:-mx-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<%!-- Header --%>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||
<.icon name="hero-cube-transparent" class="size-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-base-content">Cobblemon</h1>
|
||||
<p class="text-sm text-base-content/50">Player Data Explorer</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="refresh-btn"
|
||||
phx-click="refresh"
|
||||
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300/50 transition-colors"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<%!-- Sidebar: Player List --%>
|
||||
<aside class="w-full lg:w-72 shrink-0">
|
||||
<div class="rounded-xl border border-base-300/50 bg-base-200/30 backdrop-blur-sm">
|
||||
<div class="px-4 py-3 border-b border-base-300/30">
|
||||
<h2 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">
|
||||
Players
|
||||
</h2>
|
||||
<p class="text-xs text-base-content/40 mt-0.5">
|
||||
{length(@players)} found
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-2 max-h-[60vh] overflow-y-auto">
|
||||
<div
|
||||
:if={@players == []}
|
||||
class="px-3 py-8 text-center text-sm text-base-content/40"
|
||||
>
|
||||
<.icon name="hero-user-group" class="size-8 mx-auto mb-2 opacity-30" />
|
||||
<p>No players found</p>
|
||||
<p class="text-xs mt-1">Check data directory</p>
|
||||
</div>
|
||||
<.link
|
||||
:for={player <- @players}
|
||||
patch={~p"/player/#{player.uuid}"}
|
||||
class={[
|
||||
"block px-3 py-2.5 rounded-lg transition-all duration-150 mb-1",
|
||||
if(@selected_player == player.uuid,
|
||||
do:
|
||||
"bg-primary/15 text-primary border border-primary/20 shadow-sm shadow-primary/5",
|
||||
else: "text-base-content/60 hover:bg-base-300/40 hover:text-base-content/80"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<span class="text-sm font-medium block truncate">
|
||||
{player.name || "Unknown"}
|
||||
</span>
|
||||
<span class="text-[10px] font-mono text-base-content/30 block truncate">
|
||||
{player.uuid}
|
||||
</span>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<%!-- Main Content --%>
|
||||
<main class="flex-1 min-w-0">
|
||||
<div
|
||||
:if={@error}
|
||||
class="rounded-xl border border-error/30 bg-error/10 px-5 py-4 mb-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-error">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span class="text-sm font-medium">{@error}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Empty state --%>
|
||||
<div
|
||||
:if={is_nil(@selected_player)}
|
||||
class="rounded-xl border border-base-300/30 bg-base-200/20 px-8 py-20 text-center"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-left"
|
||||
class="size-10 mx-auto mb-4 text-base-content/20"
|
||||
/>
|
||||
<p class="text-base-content/40 text-lg">Select a player to explore</p>
|
||||
<p class="text-base-content/25 text-sm mt-1">
|
||||
Choose from the sidebar to view their Pokémon
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Player data --%>
|
||||
<div :if={@player_data}>
|
||||
<%!-- Player header --%>
|
||||
<div class="rounded-xl border border-base-300/40 bg-base-200/30 px-5 py-4 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<.icon name="hero-user" class="size-5 text-primary/70" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-base font-semibold text-base-content/90">
|
||||
{player_name(@players, @player_data.uuid)}
|
||||
</p>
|
||||
<p class="text-xs font-mono text-base-content/40 truncate">
|
||||
{@player_data.uuid}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-3 text-sm text-base-content/50">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-success/60" />
|
||||
<span>{party_count(@player_data)} in party</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-info/60" />
|
||||
<span>{pc_count(@player_data)} in PC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- View mode tabs --%>
|
||||
<div class="flex items-center gap-1 mb-5 p-1 rounded-lg bg-base-200/40 w-fit">
|
||||
<button
|
||||
id="tab-party"
|
||||
phx-click="switch_view"
|
||||
phx-value-mode="party"
|
||||
class={[
|
||||
"px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-150",
|
||||
if(@view_mode == :party,
|
||||
do: "bg-base-100 text-base-content shadow-sm",
|
||||
else: "text-base-content/50 hover:text-base-content/70"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Party
|
||||
</button>
|
||||
<button
|
||||
id="tab-pc"
|
||||
phx-click="switch_view"
|
||||
phx-value-mode="pc"
|
||||
class={[
|
||||
"px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-150",
|
||||
if(@view_mode == :pc,
|
||||
do: "bg-base-100 text-base-content shadow-sm",
|
||||
else: "text-base-content/50 hover:text-base-content/70"
|
||||
)
|
||||
]}
|
||||
>
|
||||
PC Storage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Party view --%>
|
||||
<div :if={@view_mode == :party}>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<%= for {pokemon, idx} <- Enum.with_index(@player_data.party) do %>
|
||||
<.pokemon_card pokemon={pokemon} index={idx} />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- PC view --%>
|
||||
<div :if={@view_mode == :pc}>
|
||||
<div :for={box <- @player_data.pc} class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">
|
||||
Box {box.box + 1}
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-6 gap-2">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:if={@player_data.pc == []}
|
||||
class="text-center py-12 text-base-content/30 text-sm"
|
||||
>
|
||||
PC storage is empty
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Pokémon detail panel --%>
|
||||
<.pokemon_detail :if={@selected_pokemon} pokemon={@selected_pokemon} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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"""
|
||||
<div class={[
|
||||
"rounded-lg border border-base-300/20 bg-base-200/10 flex items-center justify-center",
|
||||
if(@compact, do: "h-16", else: "h-24")
|
||||
]}>
|
||||
<span class="text-base-content/15 text-xs">Empty</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp pokemon_card(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
phx-click="select_pokemon"
|
||||
phx-value-index={@index}
|
||||
class={[
|
||||
"group rounded-lg border border-base-300/30 bg-base-200/20 hover:bg-base-200/40",
|
||||
"hover:border-primary/30 transition-all duration-150 text-left cursor-pointer",
|
||||
"hover:shadow-md hover:shadow-primary/5",
|
||||
if(@compact, do: "p-2.5", else: "p-3.5")
|
||||
]}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class={[
|
||||
"font-semibold text-base-content/90 capitalize truncate group-hover:text-primary transition-colors",
|
||||
if(@compact, do: "text-xs", else: "text-sm")
|
||||
]}>
|
||||
{@pokemon.species || "Unknown"}
|
||||
</p>
|
||||
<p class={[
|
||||
"text-base-content/40 mt-0.5",
|
||||
if(@compact, do: "text-[10px]", else: "text-xs")
|
||||
]}>
|
||||
Lv. {@pokemon.level || "?"}
|
||||
</p>
|
||||
</div>
|
||||
<div :if={@pokemon.shiny} class="shrink-0" title="Shiny">
|
||||
<.icon
|
||||
name="hero-sparkles"
|
||||
class={[
|
||||
"text-warning",
|
||||
if(@compact, do: "size-3", else: "size-4")
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div :if={!@compact} class="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
:if={@pokemon.nature}
|
||||
class="inline-block text-[10px] px-1.5 py-0.5 rounded bg-base-300/40 text-base-content/50 capitalize"
|
||||
>
|
||||
{@pokemon.nature}
|
||||
</span>
|
||||
<span
|
||||
:if={@pokemon.gender}
|
||||
class={[
|
||||
"text-[10px] font-bold",
|
||||
gender_color(@pokemon.gender)
|
||||
]}
|
||||
>
|
||||
{gender_symbol(@pokemon.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :pokemon, :map, required: true
|
||||
|
||||
defp pokemon_detail(assigns) do
|
||||
~H"""
|
||||
<div class="mt-6 rounded-xl border border-base-300/40 bg-base-200/30 overflow-hidden">
|
||||
<%!-- Detail header --%>
|
||||
<div class="px-5 py-4 border-b border-base-300/30 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<.icon name="hero-bolt" class="size-4 text-primary/70" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-base-content capitalize text-lg">
|
||||
{@pokemon.species || "Unknown"}
|
||||
<span
|
||||
:if={@pokemon.shiny}
|
||||
class="text-warning text-sm ml-1"
|
||||
title="Shiny"
|
||||
>
|
||||
★
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/40">
|
||||
Level {@pokemon.level || "?"} · {String.capitalize(@pokemon.form || "default")} form
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="close-pokemon-detail"
|
||||
phx-click="close_pokemon"
|
||||
class="btn btn-ghost btn-sm btn-circle hover:bg-base-300/50"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<%!-- Info Column --%>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
|
||||
Details
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<.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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Moves --%>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
|
||||
Moves
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
:for={move <- @pokemon.moves || []}
|
||||
class="px-3 py-1.5 rounded-md bg-base-300/20 border border-base-300/20 text-sm text-base-content/70 capitalize"
|
||||
>
|
||||
{format_move(move)}
|
||||
</div>
|
||||
<div
|
||||
:if={(@pokemon.moves || []) == []}
|
||||
class="text-xs text-base-content/30 italic"
|
||||
>
|
||||
No moves
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Stats Column --%>
|
||||
<div class="space-y-4">
|
||||
<%!-- IVs --%>
|
||||
<div :if={@pokemon.ivs}>
|
||||
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
|
||||
IVs
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
<.stat_bar
|
||||
:for={{stat, val} <- stat_list(@pokemon.ivs)}
|
||||
label={format_stat(stat)}
|
||||
value={val}
|
||||
max={31}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- EVs --%>
|
||||
<div :if={@pokemon.evs}>
|
||||
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
|
||||
EVs
|
||||
<span class="text-base-content/25 font-normal ml-1">
|
||||
({ev_total(@pokemon.evs)}/510)
|
||||
</span>
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
<.stat_bar
|
||||
:for={{stat, val} <- stat_list(@pokemon.evs)}
|
||||
label={format_stat(stat)}
|
||||
value={val}
|
||||
max={252}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
|
||||
defp stat_pill(assigns) do
|
||||
~H"""
|
||||
<div class="px-3 py-2 rounded-lg bg-base-300/15 border border-base-300/15">
|
||||
<p class="text-[10px] text-base-content/35 uppercase tracking-wider">{@label}</p>
|
||||
<p class="text-sm text-base-content/80 capitalize mt-0.5">{@value || "—"}</p>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-base-content/40 w-10 uppercase tracking-wider shrink-0">
|
||||
{@label}
|
||||
</span>
|
||||
<div class="flex-1 h-2 rounded-full bg-base-300/30 overflow-hidden">
|
||||
<div
|
||||
class={[@color, "h-full rounded-full transition-all duration-500"]}
|
||||
style={"width: #{@pct}%"}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/50 w-8 text-right tabular-nums font-mono">
|
||||
{@value}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
3
mix.lock
3
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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user