show evolutions as well
All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 33s

This commit is contained in:
2026-03-18 20:28:06 -06:00
parent a8e3c9b1cc
commit d48f23b022
7 changed files with 4431 additions and 106 deletions

View File

@@ -13,6 +13,7 @@ defmodule CobblemonUi.Application do
{Phoenix.PubSub, name: CobblemonUi.PubSub},
CobblemonUi.CobblemonFS,
CobblemonUi.TierListScraper,
CobblemonUi.EvolutionApi,
# Start to serve requests, typically the last entry
CobblemonUiWeb.Endpoint
]

View File

@@ -66,9 +66,10 @@ defmodule CobblemonUi.BattlesApi do
ability: p["ability"],
nature: p["nature"],
shiny: p["shiny"],
moves: Enum.map(p["moves"] || [], fn m ->
%{name: m["name"], pp: m["pp"], max_pp: m["maxPp"]}
end)
moves:
Enum.map(p["moves"] || [], fn m ->
%{name: m["name"], pp: m["pp"], max_pp: m["maxPp"]}
end)
}
end
end

View File

@@ -0,0 +1,141 @@
defmodule CobblemonUi.EvolutionApi do
@moduledoc """
Fetches and caches Pokémon evolution data from PokeAPI.
Provides `get_evolutions/1` which returns the list of species that
a given Pokémon can evolve into (direct next-stage evolutions).
Results are cached in an ETS table to avoid repeated API calls.
"""
use GenServer
require Logger
@table :evolution_cache
# ---------------------------------------------------------------------------
# Client API
# ---------------------------------------------------------------------------
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@doc """
Returns a list of species names (lowercase strings) that the given species
evolves into. Returns an empty list if there are no evolutions or the lookup
fails.
Results are cached after the first successful fetch.
"""
@spec get_evolutions(String.t()) :: [String.t()]
def get_evolutions(species) when is_binary(species) do
key = String.downcase(species)
case :ets.lookup(@table, key) do
[{^key, evolutions}] ->
evolutions
[] ->
GenServer.call(__MODULE__, {:fetch, key}, 15_000)
end
end
# ---------------------------------------------------------------------------
# Server callbacks
# ---------------------------------------------------------------------------
@impl true
def init(_opts) do
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
{:ok, %{}}
end
@impl true
def handle_call({:fetch, species}, _from, state) do
# Double-check cache (another caller may have populated it)
result =
case :ets.lookup(@table, species) do
[{^species, evolutions}] ->
evolutions
[] ->
evolutions = fetch_evolutions(species)
:ets.insert(@table, {species, evolutions})
evolutions
end
{:reply, result, state}
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp fetch_evolutions(species) do
with {:ok, chain_url} <- fetch_species_chain_url(species),
{:ok, chain} <- fetch_chain(chain_url) do
find_next_evolutions(chain, species)
else
_ -> []
end
end
defp fetch_species_chain_url(species) do
url = "https://pokeapi.co/api/v2/pokemon-species/#{species}"
case Req.get(url) do
{:ok, %Req.Response{status: 200, body: %{"evolution_chain" => %{"url" => url}}}} ->
{:ok, url}
{:ok, %Req.Response{status: status}} ->
Logger.warning("[EvolutionApi] Species lookup failed for #{species}: HTTP #{status}")
{:error, :not_found}
{:error, reason} ->
Logger.warning("[EvolutionApi] Species lookup failed for #{species}: #{inspect(reason)}")
{:error, reason}
end
end
defp fetch_chain(url) do
case Req.get(url) do
{:ok, %Req.Response{status: 200, body: %{"chain" => chain}}} ->
{:ok, chain}
{:ok, %Req.Response{status: status}} ->
Logger.warning("[EvolutionApi] Chain fetch failed: HTTP #{status}")
{:error, :not_found}
{:error, reason} ->
Logger.warning("[EvolutionApi] Chain fetch failed: #{inspect(reason)}")
{:error, reason}
end
end
@doc false
# Walks the evolution chain tree and returns the species names of
# the direct next-stage evolutions for the given species.
def find_next_evolutions(chain, target_species) do
target = String.downcase(target_species)
do_find(chain, target)
end
defp do_find(%{"species" => %{"name" => name}, "evolves_to" => evolves_to}, target) do
if String.downcase(name) == target do
# Found the target — return names of its direct evolutions
Enum.map(evolves_to, fn evo ->
get_in(evo, ["species", "name"]) |> String.downcase()
end)
else
# Recurse into each branch
Enum.find_value(evolves_to, [], fn evo ->
case do_find(evo, target) do
[] -> nil
result -> result
end
end)
end
end
defp do_find(_, _target), do: []
end

View File

@@ -63,7 +63,10 @@ defmodule CobblemonUi.TierListScraper do
{:noreply, tier_list}
{:error, reason} ->
Logger.error("[TierListScraper] Scrape failed, keeping existing state: #{inspect(reason)}")
Logger.error(
"[TierListScraper] Scrape failed, keeping existing state: #{inspect(reason)}"
)
{:noreply, state}
end
end
@@ -110,13 +113,22 @@ defmodule CobblemonUi.TierListScraper do
case bands do
[] ->
Logger.warning("[TierListScraper] No .tierlist-band elements found — page structure may have changed")
Logger.warning(
"[TierListScraper] No .tierlist-band elements found — page structure may have changed"
)
{:error, :no_pokemon_found}
_ ->
pokemon =
Enum.flat_map(bands, fn band ->
tier = band |> Floki.attribute("data-tier") |> List.first() |> to_string() |> String.upcase()
tier =
band
|> Floki.attribute("data-tier")
|> List.first()
|> to_string()
|> String.upcase()
cards = Floki.find(band, ".tierlist-card")
Enum.flat_map(cards, fn card ->
@@ -128,7 +140,10 @@ defmodule CobblemonUi.TierListScraper do
end)
end)
Logger.info("[TierListScraper] Parsed #{length(pokemon)} pokemon across #{length(bands)} tiers")
Logger.info(
"[TierListScraper] Parsed #{length(pokemon)} pokemon across #{length(bands)} tiers"
)
{:ok, pokemon}
end
end

View File

@@ -6,6 +6,7 @@ defmodule CobblemonUiWeb.BattleComponents do
attr :battle, :map, required: true
attr :player_id, :string, required: true
attr :tier_list, :map, default: %{}
attr :evolutions, :map, default: %{}
def battle_panel(assigns) do
~H"""
@@ -51,12 +52,15 @@ defmodule CobblemonUiWeb.BattleComponents do
</div>
<%= for poke <- actor.active_pokemon do %>
<% tier = Map.get(@tier_list, String.downcase(poke.species || ""), nil) %>
<% species_key = String.downcase(poke.species || "") %>
<% is_opponent = actor.player_id != @player_id %>
<% evos = if is_opponent, do: Map.get(@evolutions, species_key, []), else: [] %>
<div class="rounded-md bg-base-100/50 border border-base-300/30 px-3 py-2">
<div class="flex items-center gap-3">
<%!-- Sprite --%>
<div class="w-14 h-14 rounded-lg bg-base-300/20 flex items-center justify-center shrink-0 overflow-hidden">
<img
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{String.downcase(poke.species || "")}.png"}
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{species_key}.png"}
alt={poke.species}
class="w-12 h-12 object-contain drop-shadow-sm"
/>
@@ -64,7 +68,9 @@ defmodule CobblemonUiWeb.BattleComponents do
<%!-- Info --%>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-sm font-bold text-base-content capitalize">{poke.species}</span>
<span class="text-sm font-bold text-base-content capitalize">
{poke.species}
</span>
<span class="text-[10px] text-base-content/40 bg-base-300/40 px-1.5 py-0.5 rounded font-mono">
Lv.{poke.level}
</span>
@@ -92,6 +98,31 @@ defmodule CobblemonUiWeb.BattleComponents do
</div>
</div>
</div>
<%!-- Evolution info for opponent Pokémon --%>
<div :if={evos != []} class="mt-2 pt-2 border-t border-base-300/20">
<div class="flex items-center gap-1.5 mb-1.5">
<.icon name="hero-arrow-trending-up" class="size-3 text-info/60" />
<span class="text-[10px] uppercase font-semibold tracking-wide text-info/60">
Evolves into
</span>
</div>
<div class="flex flex-wrap gap-1.5">
<%= for evo <- evos do %>
<% evo_tier = Map.get(@tier_list, evo, nil) %>
<div class="flex items-center gap-1.5 rounded-md bg-info/5 border border-info/15 px-2 py-1">
<img
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{evo}.png"}
alt={evo}
class="w-6 h-6 object-contain"
/>
<span class="text-xs font-semibold text-base-content/70 capitalize">
{evo}
</span>
<.tier_badge :if={evo_tier} tier={evo_tier} species={evo} compact={true} />
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -64,7 +64,8 @@ defmodule CobblemonUiWeb.DashboardLive do
end
def handle_params(_params, _uri, socket) do
{:noreply, assign(socket, selected_player: nil, player_data: nil, battle: nil, selected_pokemon: nil)}
{:noreply,
assign(socket, selected_player: nil, player_data: nil, battle: nil, selected_pokemon: nil)}
end
@impl true
@@ -93,13 +94,11 @@ defmodule CobblemonUiWeb.DashboardLive do
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, do_refresh(socket)}
end
defp do_refresh(socket) do
players =
case CobblemonUi.CobblemonFS.list_players() do
@@ -127,7 +126,6 @@ defmodule CobblemonUiWeb.DashboardLive do
<Layouts.app flash={@flash}>
<div class="h-screen flex flex-col">
<div class="max-w-4xl w-full mx-auto px-4 sm:px-6 lg:px-8 flex flex-col flex-1 min-h-0">
<%!-- Player picker --%>
<div :if={is_nil(@selected_player)} class="flex-1 overflow-y-auto py-8">
<div class="flex items-center justify-between mb-8">
@@ -164,7 +162,10 @@ defmodule CobblemonUiWeb.DashboardLive do
<span class="text-base font-semibold text-base-content/80 group-hover:text-base-content truncate">
{player.name || "Unknown"}
</span>
<.icon name="hero-chevron-right" class="size-4 text-base-content/20 ml-auto shrink-0 group-hover:text-primary/40 transition-colors" />
<.icon
name="hero-chevron-right"
class="size-4 text-base-content/20 ml-auto shrink-0 group-hover:text-primary/40 transition-colors"
/>
</.link>
</div>
</div>
@@ -192,114 +193,117 @@ defmodule CobblemonUiWeb.DashboardLive do
<%!-- Scrollable content --%>
<div class="flex-1 overflow-y-auto py-6">
<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>
<div :if={@player_data}>
<%!-- Stats bar --%>
<div class="flex items-center gap-4 text-sm text-base-content/50 mb-6">
<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
: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>
<%!-- Active battle --%>
<.battle_panel :if={@battle} battle={@battle} player_id={@selected_player} tier_list={@tier_list} />
<%!-- 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), not is_nil(pokemon) do %>
<.pokemon_card
pokemon={pokemon}
index={idx}
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
/>
<% end %>
<div :if={@player_data}>
<%!-- Stats bar --%>
<div class="flex items-center gap-4 text-sm text-base-content/50 mb-6">
<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>
<%!-- 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), not is_nil(pokemon) do %>
<%!-- Active battle --%>
<.battle_panel
:if={@battle}
battle={@battle}
player_id={@selected_player}
tier_list={@tier_list}
evolutions={opponent_evolutions(@battle, @selected_player)}
/>
<%!-- 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), not is_nil(pokemon) do %>
<.pokemon_card
pokemon={pokemon}
index={pc_global_index(@player_data.pc, box.box, idx)}
index={idx}
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
compact
/>
<% end %>
</div>
</div>
<div
:if={@player_data.pc == []}
class="text-center py-12 text-base-content/30 text-sm"
>
PC storage is empty
<%!-- 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), not is_nil(pokemon) do %>
<.pokemon_card
pokemon={pokemon}
index={pc_global_index(@player_data.pc, box.box, idx)}
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
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>
<%!-- Pokemon detail panel --%>
<.pokemon_detail
:if={@selected_pokemon}
pokemon={@selected_pokemon}
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
/>
</div>
<%!-- Pokemon detail panel --%>
<.pokemon_detail
:if={@selected_pokemon}
pokemon={@selected_pokemon}
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
/>
</div>
</div>
</div>
</div>
</div>
</Layouts.app>
@@ -329,6 +333,24 @@ defmodule CobblemonUiWeb.DashboardLive do
end
end
defp opponent_evolutions(nil, _player_id), do: %{}
defp opponent_evolutions(battle, player_id) do
battle.actors
|> Enum.reject(fn actor -> actor.player_id == player_id end)
|> Enum.flat_map(fn actor -> actor.active_pokemon end)
|> Enum.reduce(%{}, fn poke, acc ->
species = String.downcase(poke.species || "")
if species != "" do
evolutions = CobblemonUi.EvolutionApi.get_evolutions(species)
Map.put(acc, species, evolutions)
else
acc
end
end)
end
defp find_player_battle(uuid) do
case CobblemonUi.BattlesApi.list_battles() do
{:ok, battles} ->

4114
pokemon_tier_list.json Normal file

File diff suppressed because it is too large Load Diff