show evolutions as well
All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 33s
All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 33s
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -66,7 +66,8 @@ defmodule CobblemonUi.BattlesApi do
|
||||
ability: p["ability"],
|
||||
nature: p["nature"],
|
||||
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"]}
|
||||
end)
|
||||
}
|
||||
|
||||
141
lib/cobblemon_ui/evolution_api.ex
Normal file
141
lib/cobblemon_ui/evolution_api.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +193,6 @@ 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"
|
||||
@@ -217,7 +217,13 @@ defmodule CobblemonUiWeb.DashboardLive do
|
||||
</div>
|
||||
|
||||
<%!-- 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 --%>
|
||||
<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)}
|
||||
/>
|
||||
</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
4114
pokemon_tier_list.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user