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}, {Phoenix.PubSub, name: CobblemonUi.PubSub},
CobblemonUi.CobblemonFS, CobblemonUi.CobblemonFS,
CobblemonUi.TierListScraper, CobblemonUi.TierListScraper,
CobblemonUi.EvolutionApi,
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
CobblemonUiWeb.Endpoint CobblemonUiWeb.Endpoint
] ]

View File

@@ -66,7 +66,8 @@ defmodule CobblemonUi.BattlesApi do
ability: p["ability"], ability: p["ability"],
nature: p["nature"], nature: p["nature"],
shiny: p["shiny"], shiny: p["shiny"],
moves: Enum.map(p["moves"] || [], fn m -> moves:
Enum.map(p["moves"] || [], fn m ->
%{name: m["name"], pp: m["pp"], max_pp: m["maxPp"]} %{name: m["name"], pp: m["pp"], max_pp: m["maxPp"]}
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} {:noreply, tier_list}
{:error, reason} -> {:error, reason} ->
Logger.error("[TierListScraper] Scrape failed, keeping existing state: #{inspect(reason)}") Logger.error(
"[TierListScraper] Scrape failed, keeping existing state: #{inspect(reason)}"
)
{:noreply, state} {:noreply, state}
end end
end end
@@ -110,13 +113,22 @@ defmodule CobblemonUi.TierListScraper do
case bands 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} {:error, :no_pokemon_found}
_ -> _ ->
pokemon = pokemon =
Enum.flat_map(bands, fn band -> 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") cards = Floki.find(band, ".tierlist-card")
Enum.flat_map(cards, fn card -> Enum.flat_map(cards, fn card ->
@@ -128,7 +140,10 @@ defmodule CobblemonUi.TierListScraper do
end) end)
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} {:ok, pokemon}
end end
end end

View File

@@ -6,6 +6,7 @@ defmodule CobblemonUiWeb.BattleComponents do
attr :battle, :map, required: true attr :battle, :map, required: true
attr :player_id, :string, required: true attr :player_id, :string, required: true
attr :tier_list, :map, default: %{} attr :tier_list, :map, default: %{}
attr :evolutions, :map, default: %{}
def battle_panel(assigns) do def battle_panel(assigns) do
~H""" ~H"""
@@ -51,12 +52,15 @@ defmodule CobblemonUiWeb.BattleComponents do
</div> </div>
<%= for poke <- actor.active_pokemon do %> <%= for poke <- actor.active_pokemon do %>
<% tier = Map.get(@tier_list, String.downcase(poke.species || ""), nil) %> <% 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="rounded-md bg-base-100/50 border border-base-300/30 px-3 py-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<%!-- Sprite --%> <%!-- Sprite --%>
<div class="w-14 h-14 rounded-lg bg-base-300/20 flex items-center justify-center shrink-0 overflow-hidden"> <div class="w-14 h-14 rounded-lg bg-base-300/20 flex items-center justify-center shrink-0 overflow-hidden">
<img <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} alt={poke.species}
class="w-12 h-12 object-contain drop-shadow-sm" class="w-12 h-12 object-contain drop-shadow-sm"
/> />
@@ -64,7 +68,9 @@ defmodule CobblemonUiWeb.BattleComponents do
<%!-- Info --%> <%!-- Info --%>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1"> <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"> <span class="text-[10px] text-base-content/40 bg-base-300/40 px-1.5 py-0.5 rounded font-mono">
Lv.{poke.level} Lv.{poke.level}
</span> </span>
@@ -92,6 +98,31 @@ defmodule CobblemonUiWeb.BattleComponents do
</div> </div>
</div> </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> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -64,7 +64,8 @@ defmodule CobblemonUiWeb.DashboardLive do
end end
def handle_params(_params, _uri, socket) do 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 end
@impl true @impl true
@@ -93,13 +94,11 @@ defmodule CobblemonUiWeb.DashboardLive do
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)} {:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
end end
@impl true @impl true
def handle_info(:tick, socket) do def handle_info(:tick, socket) do
{:noreply, do_refresh(socket)} {:noreply, do_refresh(socket)}
end end
defp do_refresh(socket) do defp do_refresh(socket) do
players = players =
case CobblemonUi.CobblemonFS.list_players() do case CobblemonUi.CobblemonFS.list_players() do
@@ -127,7 +126,6 @@ defmodule CobblemonUiWeb.DashboardLive do
<Layouts.app flash={@flash}> <Layouts.app flash={@flash}>
<div class="h-screen flex flex-col"> <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"> <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 --%> <%!-- Player picker --%>
<div :if={is_nil(@selected_player)} class="flex-1 overflow-y-auto py-8"> <div :if={is_nil(@selected_player)} class="flex-1 overflow-y-auto py-8">
<div class="flex items-center justify-between mb-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"> <span class="text-base font-semibold text-base-content/80 group-hover:text-base-content truncate">
{player.name || "Unknown"} {player.name || "Unknown"}
</span> </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> </.link>
</div> </div>
</div> </div>
@@ -192,7 +193,6 @@ defmodule CobblemonUiWeb.DashboardLive do
<%!-- Scrollable content --%> <%!-- Scrollable content --%>
<div class="flex-1 overflow-y-auto py-6"> <div class="flex-1 overflow-y-auto py-6">
<div <div
:if={@error} :if={@error}
class="rounded-xl border border-error/30 bg-error/10 px-5 py-4 mb-6" class="rounded-xl border border-error/30 bg-error/10 px-5 py-4 mb-6"
@@ -217,7 +217,13 @@ defmodule CobblemonUiWeb.DashboardLive do
</div> </div>
<%!-- Active battle --%> <%!-- Active battle --%>
<.battle_panel :if={@battle} battle={@battle} player_id={@selected_player} tier_list={@tier_list} /> <.battle_panel
:if={@battle}
battle={@battle}
player_id={@selected_player}
tier_list={@tier_list}
evolutions={opponent_evolutions(@battle, @selected_player)}
/>
<%!-- View mode tabs --%> <%!-- View mode tabs --%>
<div class="flex items-center gap-1 mb-5 p-1 rounded-lg bg-base-200/40 w-fit"> <div class="flex items-center gap-1 mb-5 p-1 rounded-lg bg-base-200/40 w-fit">
@@ -296,10 +302,8 @@ defmodule CobblemonUiWeb.DashboardLive do
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)} tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Layouts.app> </Layouts.app>
@@ -329,6 +333,24 @@ defmodule CobblemonUiWeb.DashboardLive do
end end
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 defp find_player_battle(uuid) do
case CobblemonUi.BattlesApi.list_battles() do case CobblemonUi.BattlesApi.list_battles() do
{:ok, battles} -> {:ok, battles} ->

4114
pokemon_tier_list.json Normal file

File diff suppressed because it is too large Load Diff