diff --git a/lib/safira/minigames.ex b/lib/safira/minigames.ex index 117c69a3..44796c1e 100644 --- a/lib/safira/minigames.ex +++ b/lib/safira/minigames.ex @@ -12,7 +12,7 @@ defmodule Safira.Minigames do alias Safira.Constants alias Safira.Contest alias Safira.Inventory.Item - alias Safira.Minigames.{Prize, WheelDrop} + alias Safira.Minigames.{Prize, WheelDrop, WheelSpin} @pubsub Safira.PubSub @@ -155,7 +155,10 @@ defmodule Safira.Minigames do """ def list_wheel_drops do - Repo.all(WheelDrop) + WheelDrop + |> order_by([wd], asc: wd.probability) + |> Repo.all() + |> Repo.preload([:badge, :prize]) end @doc """ @@ -187,9 +190,13 @@ defmodule Safira.Minigames do """ def create_wheel_drop(attrs \\ %{}) do - %WheelDrop{} - |> WheelDrop.changeset(attrs) - |> Repo.insert() + result = + %WheelDrop{} + |> WheelDrop.changeset(attrs) + |> Repo.insert() + + broadcast_wheel_config_update("drops", list_wheel_drops()) + result end @doc """ @@ -205,9 +212,13 @@ defmodule Safira.Minigames do """ def update_wheel_drop(%WheelDrop{} = wheel_drop, attrs) do - wheel_drop - |> WheelDrop.changeset(attrs) - |> Repo.update() + result = + wheel_drop + |> WheelDrop.changeset(attrs) + |> Repo.update() + + broadcast_wheel_config_update("drops", list_wheel_drops()) + result end @doc """ @@ -223,7 +234,9 @@ defmodule Safira.Minigames do """ def delete_wheel_drop(%WheelDrop{} = wheel_drop) do - Repo.delete(wheel_drop) + result = Repo.delete(wheel_drop) + broadcast_wheel_config_update("drops", list_wheel_drops()) + result end @doc """ @@ -297,6 +310,14 @@ defmodule Safira.Minigames do end end + def wheel_latest_wins(count) do + WheelSpin + |> order_by([ws], desc: ws.inserted_at) + |> limit(^count) + |> Repo.all() + |> Repo.preload(attendee: [:user], drop: [:prize, :badge]) + end + defp spin_wheel_transaction(attendee) do Multi.new() # Fetch the wheel spin price @@ -313,10 +334,40 @@ defmodule Safira.Minigames do |> Multi.merge(fn %{drop: drop, attendee: attendee} -> drop_reward_action(drop, attendee) end) + # Add record of the spin transaction to the database + |> Multi.merge(fn %{drop: drop, attendee: attendee} -> + add_spin_action(drop, attendee) + end) + |> Multi.run(:notify, fn _repo, params -> broadcast_spin_changes(params) end) # Execute the transaction |> Repo.transaction() end + defp broadcast_spin_changes(params) do + case broadcast_wheel_win(Map.get(params, :spin)) do + :ok -> + case broadcast_wheel_config_update("drops", list_wheel_drops()) do + :ok -> {:ok, :ok} + e -> e + end + + e -> + e + end + end + + defp add_spin_action(drop, attendee) do + if is_nil(drop) or (is_nil(drop.badge_id) and is_nil(drop.prize_id)) do + # If there was no prize, or the prize was just tokens, don't insert it + Multi.new() + else + Multi.new() + |> Multi.insert(:spin, fn _ -> + WheelSpin.changeset(%WheelSpin{}, %{drop_id: drop.id, attendee_id: attendee.id}) + end) + end + end + defp generate_valid_wheel_drop(attendee) do drop = generate_wheel_drop() @@ -506,6 +557,29 @@ defmodule Safira.Minigames do Phoenix.PubSub.broadcast(@pubsub, wheel_config_topic(config), {config, value}) end + @doc """ + Subscribes the caller to the wheel's wins. + + ## Examples + + iex> subscribe_to_wheel_wins() + :ok + """ + def subscribe_to_wheel_wins do + Phoenix.PubSub.subscribe(@pubsub, "wheel_win") + end + + defp broadcast_wheel_win(value) do + value = value |> Repo.preload(attendee: [:user], drop: [:prize, :badge]) + + if not is_nil(value) and not is_nil(value.drop) and + (not is_nil(value.drop.badge) or not is_nil(value.drop.prize)) do + Phoenix.PubSub.broadcast(@pubsub, "wheel_win", {"win", value}) + else + :ok + end + end + # Generates a random number using the Erlang crypto module defp strong_randomizer do <> = diff --git a/lib/safira/minigames/wheel_spin.ex b/lib/safira/minigames/wheel_spin.ex new file mode 100644 index 00000000..aaba9697 --- /dev/null +++ b/lib/safira/minigames/wheel_spin.ex @@ -0,0 +1,23 @@ +defmodule Safira.Minigames.WheelSpin do + @moduledoc """ + Lucky wheel minigame spin result + """ + + use Safira.Schema + + @required_fields ~w(attendee_id drop_id)a + + schema "wheel_spins" do + belongs_to :attendee, Safira.Accounts.Attendee + belongs_to :drop, Safira.Minigames.WheelDrop + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(wheel_spin, attrs) do + wheel_spin + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/safira_web/live/app/wheel_live/components/awards.ex b/lib/safira_web/live/app/wheel_live/components/awards.ex new file mode 100644 index 00000000..3e76ea56 --- /dev/null +++ b/lib/safira_web/live/app/wheel_live/components/awards.ex @@ -0,0 +1,59 @@ +defmodule SafiraWeb.App.WheelLive.Components.Awards do + @moduledoc """ + Lucky wheel awards component. + """ + use SafiraWeb, :component + + attr :entries, :list, default: [] + + def awards(assigns) do + ~H""" + + + + + + + + <%= for entry <- @entries do %> + + + + + + + <% end %> +
NameStockMax. / AttendeeProbability
<%= entry_name(entry) %><%= entry_stock(entry) %><%= entry.max_per_attendee %> + <%= format_probability(entry.probability) %> +
+ """ + end + + defp entry_stock(drop) do + if is_nil(drop.prize) do + "∞" + else + drop.prize.stock + end + end + + defp format_probability(probability) do + "#{probability * 100} %" + end + + defp entry_name(drop) do + cond do + not is_nil(drop.prize) -> + drop.prize.name + + not is_nil(drop.badge) -> + drop.badge.name + + drop.entries > 0 -> + "#{drop.entries} Entries" + + drop.tokens > 0 -> + "#{drop.tokens} Tokens" + end + end +end diff --git a/lib/safira_web/live/app/wheel_live/components/latest_wins.ex b/lib/safira_web/live/app/wheel_live/components/latest_wins.ex new file mode 100644 index 00000000..55fd30d4 --- /dev/null +++ b/lib/safira_web/live/app/wheel_live/components/latest_wins.ex @@ -0,0 +1,37 @@ +defmodule SafiraWeb.App.WheelLive.Components.LatestWins do + @moduledoc """ + Lucky wheel latest wins component. + """ + use SafiraWeb, :component + + attr :entries, :list, default: [] + + def latest_wins(assigns) do + ~H""" + + + + + + + <%= for entry <- @entries do %> + + + + + + <% end %> +
<%= gettext("Attendee") %><%= gettext("Prize") %><%= gettext("When") %>
<%= entry.attendee.user.name %><%= entry_name(entry) %> + <%= Timex.from_now(entry.inserted_at) %> +
+ """ + end + + defp entry_name(entry) do + if is_nil(entry.drop.badge) do + entry.drop.prize.name + else + entry.drop.badge.name + end + end +end diff --git a/lib/safira_web/live/app/wheel_live/index.ex b/lib/safira_web/live/app/wheel_live/index.ex index 8afcf4e8..91ec682c 100644 --- a/lib/safira_web/live/app/wheel_live/index.ex +++ b/lib/safira_web/live/app/wheel_live/index.ex @@ -1,16 +1,22 @@ defmodule SafiraWeb.App.WheelLive.Index do use SafiraWeb, :app_view + import SafiraWeb.App.WheelLive.Components.LatestWins + import SafiraWeb.App.WheelLive.Components.Awards import SafiraWeb.App.WheelLive.Components.ResultModal import SafiraWeb.App.WheelLive.Components.Wheel alias Safira.Minigames + @max_wins 6 + @impl true def mount(_params, _session, socket) do if connected?(socket) do Minigames.subscribe_to_wheel_config_update("price") Minigames.subscribe_to_wheel_config_update("is_active") + Minigames.subscribe_to_wheel_config_update("drops") + Minigames.subscribe_to_wheel_wins() end {:ok, @@ -20,7 +26,9 @@ defmodule SafiraWeb.App.WheelLive.Index do |> assign(:attendee_tokens, socket.assigns.current_user.attendee.tokens) |> assign(:wheel_price, Minigames.get_wheel_price()) |> assign(:result, nil) - |> assign(:wheel_active?, Minigames.wheel_active?())} + |> assign(:wheel_active?, Minigames.wheel_active?()) + |> assign(:latest_wins, Minigames.wheel_latest_wins(@max_wins)) + |> assign(:drops, Minigames.list_wheel_drops())} end @impl true @@ -82,6 +90,23 @@ defmodule SafiraWeb.App.WheelLive.Index do {:noreply, socket |> assign(:wheel_active?, value)} end + @impl true + def handle_info({"drops", value}, socket) do + {:noreply, socket |> assign(:drops, value)} + end + + @impl true + def handle_info({"win", value}, socket) do + {:noreply, + socket + |> assign(:latest_wins, merge_wins(socket.assigns.latest_wins, value))} + end + + defp merge_wins(latest_wins, new_win) do + ([new_win] ++ latest_wins) + |> Enum.take(@max_wins) + end + defp can_spin?(wheel_active?, tokens, price, in_spin?) do !in_spin? && wheel_active? && tokens >= price end diff --git a/lib/safira_web/live/app/wheel_live/index.html.heex b/lib/safira_web/live/app/wheel_live/index.html.heex index b0ad89a9..e89961fc 100644 --- a/lib/safira_web/live/app/wheel_live/index.html.heex +++ b/lib/safira_web/live/app/wheel_live/index.html.heex @@ -4,15 +4,34 @@ 💰 <%= @attendee_tokens %> - <.wheel /> -
- <.action_button - title={gettext("Spin")} - subtitle={"💰 #{@wheel_price}"} - class="w-64" - disabled={!can_spin?(@wheel_active?, @attendee_tokens, @wheel_price, @in_spin?)} - phx-click="spin-wheel" - /> +
+
+
+

+ <%= gettext("Spin To Win!") %> +

+
+ <.wheel /> +
+ <.action_button + title={gettext("Spin")} + subtitle={"💰 #{@wheel_price}"} + class="w-64" + disabled={!can_spin?(@wheel_active?, @attendee_tokens, @wheel_price, @in_spin?)} + phx-click="spin-wheel" + /> +
+
+
+

+ <%= gettext("Latest Wins") %> +

+ <.latest_wins entries={@latest_wins} /> +

+ <%= gettext("Awards") %> +

+ <.awards entries={@drops} /> +
diff --git a/priv/repo/migrations/20241217113451_add_wheel_spins.exs b/priv/repo/migrations/20241217113451_add_wheel_spins.exs new file mode 100644 index 00000000..41b48eea --- /dev/null +++ b/priv/repo/migrations/20241217113451_add_wheel_spins.exs @@ -0,0 +1,17 @@ +defmodule Safira.Repo.Migrations.AddWheelSpins do + use Ecto.Migration + + def change do + create table(:wheel_spins, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :attendee_id, references(:attendees, type: :binary_id, on_delete: :delete_all), + null: false + + add :drop_id, references(:wheel_drops, type: :binary_id, on_delete: :delete_all), + null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test/safira/minigames_test.exs b/test/safira/minigames_test.exs index 222f741c..90b10046 100644 --- a/test/safira/minigames_test.exs +++ b/test/safira/minigames_test.exs @@ -68,7 +68,7 @@ defmodule Safira.MinigamesTest do test "list_wheel_drops/0 returns all wheel_drops" do wheel_drop = wheel_drop_fixture() - assert Minigames.list_wheel_drops() == [wheel_drop] + assert Enum.map(Minigames.list_wheel_drops(), fn d -> d.id end) == [wheel_drop.id] end test "get_wheel_drop!/1 returns the wheel_drop with given id" do