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

Box {box.box + 1}

-
+
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon), not is_nil(pokemon) do %> - <.pokemon_card + <% skey = String.downcase(pokemon.species || "") %> + <% info = Map.get(@species_info, skey, %{}) %> + <.pc_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 + tier={Map.get(@tier_list, skey, nil)} + types={Map.get(info, :types, [])} + evo_tiers={Map.get(info, :evo_tiers, [])} /> <% end %>
@@ -301,6 +324,9 @@ defmodule CobblemonUiWeb.DashboardLive do pokemon={@selected_pokemon} tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)} /> + + <%!-- Type Chart --%> + <.type_chart selected_type={@selected_type} />
@@ -333,6 +359,38 @@ defmodule CobblemonUiWeb.DashboardLive do end end + defp compute_species_info(player_data, tier_list) do + all_pokemon = + (player_data.party ++ Enum.flat_map(player_data.pc, & &1.pokemon)) + |> Enum.reject(&is_nil/1) + + unique_species = + all_pokemon + |> Enum.map(&String.downcase(&1.species || "")) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + unique_species + |> Task.async_stream( + fn species -> + types = CobblemonUi.PokeApi.get_types(species) + evolutions = CobblemonUi.EvolutionApi.get_evolutions(species) + + evo_tiers = + Enum.map(evolutions, fn evo -> + %{species: evo, tier: Map.get(tier_list, evo)} + end) + + {species, %{types: types, evo_tiers: evo_tiers}} + end, + max_concurrency: 10, + timeout: :infinity + ) + |> Enum.reduce(%{}, fn {:ok, {species, info}}, acc -> + Map.put(acc, species, info) + end) + end + defp opponent_evolutions(nil, _player_id), do: %{} defp opponent_evolutions(battle, player_id) do diff --git a/lib/cobblemon_ui_web/live/pokemon_components.ex b/lib/cobblemon_ui_web/live/pokemon_components.ex index 571d701..482de5d 100644 --- a/lib/cobblemon_ui_web/live/pokemon_components.ex +++ b/lib/cobblemon_ui_web/live/pokemon_components.ex @@ -89,6 +89,76 @@ defmodule CobblemonUiWeb.PokemonComponents do """ end + attr :pokemon, :map, required: true + attr :index, :integer, required: true + attr :tier, :string, default: nil + attr :types, :list, default: [] + attr :evo_tiers, :list, default: [] + + def pc_pokemon_card(assigns) do + ~H""" +
+
+ <%!-- Sprite --%> +
+ {@pokemon.species} +
+ <%!-- Info --%> +
+
+ + {@pokemon.species || "Unknown"} + + <.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} compact={true} /> +
+

+ Lv. {@pokemon.level || "?"} +

+ <%!-- Types --%> +
+ + {type} + +
+
+
+ <%!-- Evolution tiers --%> +
+
+ + Evo + + <%= for evo <- @evo_tiers do %> +
+ {evo.species} + {evo.species} + <.tier_badge :if={evo.tier} tier={evo.tier} species={evo.species} compact={true} /> +
+ <% end %> +
+
+
+ """ + end + attr :tier, :string, required: true attr :species, :string, required: true attr :compact, :boolean, default: false @@ -281,6 +351,232 @@ defmodule CobblemonUiWeb.PokemonComponents do """ end + # --- Type Chart Component --- + + alias CobblemonUi.TypeChart + + attr :selected_type, :string, default: nil + + def type_chart(assigns) do + chart = TypeChart.defensive_chart() + offense = TypeChart.offensive_chart() + types = TypeChart.types() + + assigns = assign(assigns, chart: chart, offense: offense, types: types) + + ~H""" +
+
+
+ <.icon name="hero-table-cells" class="size-5 text-primary/80" /> +
+
+

Type Chart

+

+ Tap a type to see its matchups +

+
+
+ + <%!-- Type picker grid --%> +
+
+ +
+
+ + <%!-- Selected type detail --%> + <%= if @selected_type do %> +
+
+
+ <.icon name={TypeChart.type_icon(@selected_type)} class="size-6 text-white drop-shadow" /> +
+
+

{@selected_type}

+

Defensive & Offensive matchups

+
+ +
+ +
+ <%!-- Defensive --%> +
+
+ <.icon name="hero-shield-check" class="size-3.5" /> Defensive +
+ + <%!-- Weak to --%> +
+

+ <.icon name="hero-exclamation-triangle" class="size-3" /> Weak to (2×) +

+
+ <.type_pill + :for={t <- @chart[@selected_type].weak_to} + type_name={t} + /> + + None + +
+
+ + <%!-- Resists --%> +
+

+ <.icon name="hero-shield-check" class="size-3" /> Resists (½×) +

+
+ <.type_pill + :for={t <- @chart[@selected_type].resists} + type_name={t} + /> + + None + +
+
+ + <%!-- Immune to --%> +
+

+ <.icon name="hero-no-symbol" class="size-3" /> Immune to (0×) +

+
+ <.type_pill + :for={t <- @chart[@selected_type].immune_to} + type_name={t} + /> +
+
+
+ + <%!-- Offensive --%> +
+
+ <.icon name="hero-bolt" class="size-3.5" /> Offensive +
+ + <%!-- Super effective --%> +
+

+ <.icon name="hero-bolt" class="size-3" /> Super effective (2×) +

+
+ <.type_pill + :for={t <- @offense[@selected_type].strong_against} + type_name={t} + /> + + None + +
+
+ + <%!-- Not very effective --%> +
+

+ <.icon name="hero-shield-exclamation" class="size-3" /> Not very effective (½×) +

+
+ <.type_pill + :for={t <- @offense[@selected_type].not_effective} + type_name={t} + /> + + None + +
+
+ + <%!-- No effect --%> +
+

+ <.icon name="hero-no-symbol" class="size-3" /> No effect (0×) +

+
+ <.type_pill + :for={t <- @offense[@selected_type].no_effect} + type_name={t} + /> +
+
+
+
+
+ <% else %> + <%!-- Compact overview when no type selected --%> +
+

Select a type above to view matchups

+
+ <% end %> +
+ """ + end + + attr :type_name, :string, required: true + + def type_pill(assigns) do + ~H""" + + """ + end + + defp type_bg(true, type), do: TypeChart.type_color(type) + defp type_bg(false, type), do: TypeChart.type_color(type) <> "/60" + # --- Helpers --- defp gender_symbol("male"), do: "♂"