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,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
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user