diff --git a/lib/cobblemon_ui/application.ex b/lib/cobblemon_ui/application.ex index a813c87..cffcc36 100644 --- a/lib/cobblemon_ui/application.ex +++ b/lib/cobblemon_ui/application.ex @@ -14,6 +14,7 @@ defmodule CobblemonUi.Application do CobblemonUi.CobblemonFS, CobblemonUi.TierListScraper, CobblemonUi.EvolutionApi, + CobblemonUi.PokeApi, # Start to serve requests, typically the last entry CobblemonUiWeb.Endpoint ] diff --git a/lib/cobblemon_ui/poke_api.ex b/lib/cobblemon_ui/poke_api.ex new file mode 100644 index 0000000..11a9bd7 --- /dev/null +++ b/lib/cobblemon_ui/poke_api.ex @@ -0,0 +1,89 @@ +defmodule CobblemonUi.PokeApi do + @moduledoc """ + Fetches and caches Pokémon type data from PokeAPI. + + Uses an ETS table for fast lookups. Data is fetched once per species + and cached for the lifetime of the process. + """ + + use GenServer + require Logger + + @table :poke_api_types_cache + + # --------------------------------------------------------------------------- + # Client API + # --------------------------------------------------------------------------- + + def start_link(_opts) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc """ + Returns a list of type names (lowercase strings) for the given species. + + Example: `get_types("bulbasaur")` → `["grass", "poison"]` + """ + @spec get_types(String.t()) :: [String.t()] + def get_types(species) when is_binary(species) do + key = String.downcase(species) + + case :ets.lookup(@table, key) do + [{^key, types}] -> + types + + [] -> + GenServer.call(__MODULE__, {:fetch_types, 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_types, species}, _from, state) do + result = + case :ets.lookup(@table, species) do + [{^species, types}] -> + types + + [] -> + types = fetch_types(species) + :ets.insert(@table, {species, types}) + types + end + + {:reply, result, state} + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp fetch_types(species) do + url = "https://pokeapi.co/api/v2/pokemon/#{species}" + + case Req.get(url) do + {:ok, %Req.Response{status: 200, body: %{"types" => types}}} -> + types + |> Enum.sort_by(fn t -> t["slot"] end) + |> Enum.map(fn t -> get_in(t, ["type", "name"]) end) + |> Enum.reject(&is_nil/1) + + {:ok, %Req.Response{status: status}} -> + Logger.warning("[PokeApi] Type lookup failed for #{species}: HTTP #{status}") + [] + + {:error, reason} -> + Logger.warning("[PokeApi] Type lookup failed for #{species}: #{inspect(reason)}") + [] + end + end +end diff --git a/lib/cobblemon_ui/type_chart.ex b/lib/cobblemon_ui/type_chart.ex new file mode 100644 index 0000000..f46a2d0 --- /dev/null +++ b/lib/cobblemon_ui/type_chart.ex @@ -0,0 +1,272 @@ +defmodule CobblemonUi.TypeChart do + @moduledoc """ + Pokemon type effectiveness data. Contains strengths (super effective), + weaknesses (weak to), resistances (not very effective against), and immunities. + """ + + @types ~w(normal fire water grass electric ice fighting poison ground flying psychic bug rock ghost dragon dark steel fairy) + + def types, do: @types + + @doc """ + Returns a map of type -> %{weak_to: [...], resists: [...], immune_to: [...]} + representing the defensive matchups for each type. + """ + def defensive_chart do + %{ + "normal" => %{weak_to: ["fighting"], resists: [], immune_to: ["ghost"]}, + "fire" => %{ + weak_to: ["water", "ground", "rock"], + resists: ["fire", "grass", "ice", "bug", "steel", "fairy"], + immune_to: [] + }, + "water" => %{ + weak_to: ["electric", "grass"], + resists: ["fire", "water", "ice", "steel"], + immune_to: [] + }, + "grass" => %{ + weak_to: ["fire", "ice", "poison", "flying", "bug"], + resists: ["water", "electric", "grass", "ground"], + immune_to: [] + }, + "electric" => %{ + weak_to: ["ground"], + resists: ["electric", "flying", "steel"], + immune_to: [] + }, + "ice" => %{weak_to: ["fire", "fighting", "rock", "steel"], resists: ["ice"], immune_to: []}, + "fighting" => %{ + weak_to: ["flying", "psychic", "fairy"], + resists: ["bug", "rock", "dark"], + immune_to: [] + }, + "poison" => %{ + weak_to: ["ground", "psychic"], + resists: ["grass", "fighting", "poison", "bug", "fairy"], + immune_to: [] + }, + "ground" => %{ + weak_to: ["water", "grass", "ice"], + resists: ["poison", "rock"], + immune_to: ["electric"] + }, + "flying" => %{ + weak_to: ["electric", "ice", "rock"], + resists: ["grass", "fighting", "bug"], + immune_to: ["ground"] + }, + "psychic" => %{ + weak_to: ["bug", "ghost", "dark"], + resists: ["fighting", "psychic"], + immune_to: [] + }, + "bug" => %{ + weak_to: ["fire", "flying", "rock"], + resists: ["grass", "fighting", "ground"], + immune_to: [] + }, + "rock" => %{ + weak_to: ["water", "grass", "fighting", "ground", "steel"], + resists: ["normal", "fire", "poison", "flying"], + immune_to: [] + }, + "ghost" => %{ + weak_to: ["ghost", "dark"], + resists: ["poison", "bug"], + immune_to: ["normal", "fighting"] + }, + "dragon" => %{ + weak_to: ["ice", "dragon", "fairy"], + resists: ["fire", "water", "electric", "grass"], + immune_to: [] + }, + "dark" => %{ + weak_to: ["fighting", "bug", "fairy"], + resists: ["ghost", "dark"], + immune_to: ["psychic"] + }, + "steel" => %{ + weak_to: ["fire", "fighting", "ground"], + resists: [ + "normal", + "grass", + "ice", + "flying", + "psychic", + "bug", + "rock", + "dragon", + "steel", + "fairy" + ], + immune_to: ["poison"] + }, + "fairy" => %{ + weak_to: ["poison", "steel"], + resists: ["fighting", "bug", "dark"], + immune_to: ["dragon"] + } + } + end + + @doc """ + Returns a map of type -> %{strong_against: [...], not_effective: [...], no_effect: [...]} + representing the offensive matchups for each type. + """ + def offensive_chart do + %{ + "normal" => %{strong_against: [], not_effective: ["rock", "steel"], no_effect: ["ghost"]}, + "fire" => %{ + strong_against: ["grass", "ice", "bug", "steel"], + not_effective: ["fire", "water", "rock", "dragon"], + no_effect: [] + }, + "water" => %{ + strong_against: ["fire", "ground", "rock"], + not_effective: ["water", "grass", "dragon"], + no_effect: [] + }, + "grass" => %{ + strong_against: ["water", "ground", "rock"], + not_effective: ["fire", "grass", "poison", "flying", "bug", "dragon", "steel"], + no_effect: [] + }, + "electric" => %{ + strong_against: ["water", "flying"], + not_effective: ["electric", "grass", "dragon"], + no_effect: ["ground"] + }, + "ice" => %{ + strong_against: ["grass", "ground", "flying", "dragon"], + not_effective: ["fire", "water", "ice", "steel"], + no_effect: [] + }, + "fighting" => %{ + strong_against: ["normal", "ice", "rock", "dark", "steel"], + not_effective: ["poison", "flying", "psychic", "bug", "fairy"], + no_effect: ["ghost"] + }, + "poison" => %{ + strong_against: ["grass", "fairy"], + not_effective: ["poison", "ground", "rock", "ghost"], + no_effect: ["steel"] + }, + "ground" => %{ + strong_against: ["fire", "electric", "poison", "rock", "steel"], + not_effective: ["grass", "bug"], + no_effect: ["flying"] + }, + "flying" => %{ + strong_against: ["grass", "fighting", "bug"], + not_effective: ["electric", "rock", "steel"], + no_effect: [] + }, + "psychic" => %{ + strong_against: ["fighting", "poison"], + not_effective: ["psychic", "steel"], + no_effect: ["dark"] + }, + "bug" => %{ + strong_against: ["grass", "psychic", "dark"], + not_effective: ["fire", "fighting", "poison", "flying", "ghost", "steel", "fairy"], + no_effect: [] + }, + "rock" => %{ + strong_against: ["fire", "ice", "flying", "bug"], + not_effective: ["fighting", "ground", "steel"], + no_effect: [] + }, + "ghost" => %{ + strong_against: ["psychic", "ghost"], + not_effective: ["dark"], + no_effect: ["normal"] + }, + "dragon" => %{strong_against: ["dragon"], not_effective: ["steel"], no_effect: ["fairy"]}, + "dark" => %{ + strong_against: ["psychic", "ghost"], + not_effective: ["fighting", "dark", "fairy"], + no_effect: [] + }, + "steel" => %{ + strong_against: ["ice", "rock", "fairy"], + not_effective: ["fire", "water", "electric", "steel"], + no_effect: [] + }, + "fairy" => %{ + strong_against: ["fighting", "dragon", "dark"], + not_effective: ["fire", "poison", "steel"], + no_effect: [] + } + } + end + + @doc """ + Returns the color class for a given type. + """ + def type_color("normal"), do: "bg-[#A8A77A]" + def type_color("fire"), do: "bg-[#EE8130]" + def type_color("water"), do: "bg-[#6390F0]" + def type_color("grass"), do: "bg-[#7AC74C]" + def type_color("electric"), do: "bg-[#F7D02C]" + def type_color("ice"), do: "bg-[#96D9D6]" + def type_color("fighting"), do: "bg-[#C22E28]" + def type_color("poison"), do: "bg-[#A33EA1]" + def type_color("ground"), do: "bg-[#E2BF65]" + def type_color("flying"), do: "bg-[#A98FF3]" + def type_color("psychic"), do: "bg-[#F95587]" + def type_color("bug"), do: "bg-[#A6B91A]" + def type_color("rock"), do: "bg-[#B6A136]" + def type_color("ghost"), do: "bg-[#735797]" + def type_color("dragon"), do: "bg-[#6F35FC]" + def type_color("dark"), do: "bg-[#705746]" + def type_color("steel"), do: "bg-[#B7B7CE]" + def type_color("fairy"), do: "bg-[#D685AD]" + def type_color(_), do: "bg-base-300" + + @doc """ + Returns the text color class for a given type. + """ + def type_text_color("normal"), do: "text-[#A8A77A]" + def type_text_color("fire"), do: "text-[#EE8130]" + def type_text_color("water"), do: "text-[#6390F0]" + def type_text_color("grass"), do: "text-[#7AC74C]" + def type_text_color("electric"), do: "text-[#F7D02C]" + def type_text_color("ice"), do: "text-[#96D9D6]" + def type_text_color("fighting"), do: "text-[#C22E28]" + def type_text_color("poison"), do: "text-[#A33EA1]" + def type_text_color("ground"), do: "text-[#E2BF65]" + def type_text_color("flying"), do: "text-[#A98FF3]" + def type_text_color("psychic"), do: "text-[#F95587]" + def type_text_color("bug"), do: "text-[#A6B91A]" + def type_text_color("rock"), do: "text-[#B6A136]" + def type_text_color("ghost"), do: "text-[#735797]" + def type_text_color("dragon"), do: "text-[#6F35FC]" + def type_text_color("dark"), do: "text-[#705746]" + def type_text_color("steel"), do: "text-[#B7B7CE]" + def type_text_color("fairy"), do: "text-[#D685AD]" + def type_text_color(_), do: "text-base-content/50" + + @doc """ + Returns a heroicon name for each type. + """ + def type_icon("normal"), do: "hero-minus-circle" + def type_icon("fire"), do: "hero-fire" + def type_icon("water"), do: "hero-beaker" + def type_icon("grass"), do: "hero-puzzle-piece" + def type_icon("electric"), do: "hero-bolt" + def type_icon("ice"), do: "hero-cloud" + def type_icon("fighting"), do: "hero-hand-raised" + def type_icon("poison"), do: "hero-beaker" + def type_icon("ground"), do: "hero-globe-americas" + def type_icon("flying"), do: "hero-paper-airplane" + def type_icon("psychic"), do: "hero-eye" + def type_icon("bug"), do: "hero-bug-ant" + def type_icon("rock"), do: "hero-cube" + def type_icon("ghost"), do: "hero-moon" + def type_icon("dragon"), do: "hero-star" + def type_icon("dark"), do: "hero-moon" + def type_icon("steel"), do: "hero-shield-check" + def type_icon("fairy"), do: "hero-sparkles" + def type_icon(_), do: "hero-question-mark-circle" +end diff --git a/lib/cobblemon_ui_web/live/dashboard_live.ex b/lib/cobblemon_ui_web/live/dashboard_live.ex index 04bf051..f17e3c9 100644 --- a/lib/cobblemon_ui_web/live/dashboard_live.ex +++ b/lib/cobblemon_ui_web/live/dashboard_live.ex @@ -24,9 +24,11 @@ defmodule CobblemonUiWeb.DashboardLive do battle: nil, selected_pokemon: nil, tier_list: CobblemonUi.TierListScraper.get_tier_list(), + species_info: %{}, view_mode: :party, loading: false, - error: nil + error: nil, + selected_type: nil )} end @@ -36,14 +38,19 @@ defmodule CobblemonUiWeb.DashboardLive do {:ok, data} -> battle = find_player_battle(uuid) - {:noreply, - assign(socket, - selected_player: uuid, - player_data: data, - battle: battle, - selected_pokemon: nil, - error: nil - )} + socket = + assign(socket, + selected_player: uuid, + player_data: data, + battle: battle, + selected_pokemon: nil, + species_info: %{}, + error: nil + ) + + if connected?(socket), do: send(self(), {:load_species_info, data}) + + {:noreply, socket} {:error, :not_found} -> {:noreply, @@ -94,11 +101,24 @@ defmodule CobblemonUiWeb.DashboardLive do {:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)} end + def handle_event("select_type", %{"type" => ""}, socket) do + {:noreply, assign(socket, selected_type: nil)} + end + + def handle_event("select_type", %{"type" => type}, socket) do + {:noreply, assign(socket, selected_type: type)} + end + @impl true def handle_info(:tick, socket) do {:noreply, do_refresh(socket)} end + def handle_info({:load_species_info, player_data}, socket) do + species_info = compute_species_info(player_data, socket.assigns.tier_list) + {:noreply, assign(socket, species_info: species_info)} + end + defp do_refresh(socket) do players = case CobblemonUi.CobblemonFS.list_players() do @@ -276,13 +296,16 @@ defmodule CobblemonUiWeb.DashboardLive do
+ Lv. {@pokemon.level || "?"} +
+ <%!-- Types --%> ++ Tap a type to see its matchups +
+Defensive & Offensive matchups
++ <.icon name="hero-exclamation-triangle" class="size-3" /> Weak to (2×) +
++ <.icon name="hero-shield-check" class="size-3" /> Resists (½×) +
++ <.icon name="hero-no-symbol" class="size-3" /> Immune to (0×) +
++ <.icon name="hero-bolt" class="size-3" /> Super effective (2×) +
++ <.icon name="hero-shield-exclamation" class="size-3" /> Not very effective (½×) +
++ <.icon name="hero-no-symbol" class="size-3" /> No effect (0×) +
+Select a type above to view matchups
+