From 16ab39d935d43960953702e40c1fc3febb0f41d5 Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Sat, 25 Jan 2025 20:08:56 +0000 Subject: [PATCH 01/12] feat: slots minigame --- assets/css/components.css | 3 +- assets/css/components/slots_reel.css | 4 + assets/js/app.js | 3 +- assets/js/hooks/ReelAnimation.js | 70 +++++ assets/js/hooks/index.js | 1 + lib/safira/minigames.ex | 208 +++++++++++++- lib/safira/minigames/slots_paytable.ex | 23 ++ lib/safira/minigames/slots_reel.ex | 33 +++ lib/safira/uploaders/slots_reel.ex | 28 ++ lib/safira_web/components/image_uploader.ex | 21 +- lib/safira_web/config.ex | 6 + .../live/app/slots_live/components/machine.ex | 77 +++++ .../app/slots_live/components/result_modal.ex | 118 ++++++++ lib/safira_web/live/app/slots_live/index.ex | 100 +++++++ .../live/app/slots_live/index.html.heex | 27 ++ .../backoffice/minigames_live/index.html.heex | 56 +++- .../slots_live/form_component.ex | 106 +++++++ .../playtable_live/form_component.ex | 99 +++++++ .../reels_icons_live/form_component.ex | 214 ++++++++++++++ .../reels_position_live/form_component.ex | 265 ++++++++++++++++++ lib/safira_web/router.ex | 7 + ...20250121020351_create_slots_reel_icons.exs | 15 + .../20250121025339_create_slots_paytables.exs | 14 + test/safira/minigames_test.exs | 162 +++++++++++ test/support/fixtures/minigames_fixtures.ex | 34 +++ 25 files changed, 1680 insertions(+), 14 deletions(-) create mode 100644 assets/css/components/slots_reel.css create mode 100644 assets/js/hooks/ReelAnimation.js create mode 100644 lib/safira/minigames/slots_paytable.ex create mode 100644 lib/safira/minigames/slots_reel.ex create mode 100644 lib/safira/uploaders/slots_reel.ex create mode 100644 lib/safira_web/live/app/slots_live/components/machine.ex create mode 100644 lib/safira_web/live/app/slots_live/components/result_modal.ex create mode 100644 lib/safira_web/live/app/slots_live/index.ex create mode 100644 lib/safira_web/live/app/slots_live/index.html.heex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex create mode 100644 priv/repo/migrations/20250121020351_create_slots_reel_icons.exs create mode 100644 priv/repo/migrations/20250121025339_create_slots_paytables.exs diff --git a/assets/css/components.css b/assets/css/components.css index 6a5f4b17d..abff2e05a 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -1,4 +1,5 @@ @import "components/avatar.css"; @import "components/field.css"; @import "components/dropdown.css"; -@import "components/coinflip.css"; \ No newline at end of file +@import "components/coinflip.css"; +@import "components/slots_reel.css" \ No newline at end of file diff --git a/assets/css/components/slots_reel.css b/assets/css/components/slots_reel.css new file mode 100644 index 000000000..deda32585 --- /dev/null +++ b/assets/css/components/slots_reel.css @@ -0,0 +1,4 @@ +.reel-slot { + width: 79px; + height: 237px; +} \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 505b6ab60..e005d4069 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,7 +22,7 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import live_select from "live_select" -import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene } from "./hooks"; +import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene, ReelAnimation } from "./hooks"; let Hooks = { QrScanner: QrScanner, @@ -33,6 +33,7 @@ let Hooks = { CoinFlip: CoinFlip, Redirect: Redirect, CredentialScene: CredentialScene, + ReelAnimation: ReelAnimation, ...live_select }; diff --git a/assets/js/hooks/ReelAnimation.js b/assets/js/hooks/ReelAnimation.js new file mode 100644 index 000000000..e1730fc8b --- /dev/null +++ b/assets/js/hooks/ReelAnimation.js @@ -0,0 +1,70 @@ +const rotationSpeed = 100 +const extraTime = 1000 +const numIcons = 9 +const iconSize = 79 + +export const ReelAnimation = { + mounted() { + this.positions = Array(3).fill(0) + this.absolutePositions = Array(3).fill(0) + this.rotations = Array(3).fill(0) + + this.handleEvent("roll_reels", ({ multiplier, target }) => { + const reelsList = document.querySelectorAll(".slots-container > .reel-slot") + const promises = [] + + for (let i = 0; i < reelsList.length; i++) { + promises.push(this.roll(reelsList[i], i, target[i])) + } + + Promise.all(promises).then(() => { + this.pushEvent("roll_complete", { positions: this.positions }) + }) + }) + }, + + roll(reel, reelIndex, target) { + const minSpins = reelIndex + 2 + const currentPos = this.absolutePositions[reelIndex] + const currentIcon = Math.floor((currentPos / iconSize) % numIcons) + + // Calculate forward distance to target + let distance = target - currentIcon + if (distance <= 0) { + distance += numIcons + } + + const spinsInPixels = minSpins * numIcons * iconSize + const targetPixels = distance * iconSize + const delta = spinsInPixels + targetPixels + + return new Promise((resolve) => { + const newPosition = currentPos + delta + + setTimeout(() => { + const style = window.getComputedStyle(reel) + const backgroundImage = style.backgroundImage + const numImages = backgroundImage.split(',').length + + const duration = (8 + delta/iconSize) * rotationSpeed + const transitions = Array(numImages).fill(`background-position-y ${duration}ms cubic-bezier(.41,-0.01,.63,1.09)`) + + // Use Array.from to ensure proper array creation and mapping + const positions = Array.from({length: numImages}, (_, index) => { + const initialOffset = index * iconSize + return `${newPosition + initialOffset}px` + }) + + reel.style.transition = transitions.join(', ') + reel.style.backgroundPositionY = positions.join(', ') + }, reelIndex * 150) + + setTimeout(() => { + this.absolutePositions[reelIndex] = newPosition + this.positions[reelIndex] = Math.floor((newPosition / iconSize) % numIcons) + this.rotations[reelIndex]++ + resolve() + }, (8 + delta/iconSize) * rotationSpeed + reelIndex * 150 + extraTime) + }) +} +} \ No newline at end of file diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index f3f17020e..1acfaf153 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -6,3 +6,4 @@ export { Countdown } from "./countdown.js"; export { CoinFlip } from "./coinflip.js"; export { Redirect } from "./redirect.js"; export { CredentialScene } from "./credential-scene.js"; +export { ReelAnimation } from "./ReelAnimation.js"; \ No newline at end of file diff --git a/lib/safira/minigames.ex b/lib/safira/minigames.ex index 1a01cfec6..737ad0d72 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.{CoinFlipRoom, Prize, WheelDrop} + alias Safira.Minigames.{CoinFlipRoom, Prize, SlotsPaytable, SlotsReelIcon, WheelDrop} @pubsub Safira.PubSub @@ -969,4 +969,210 @@ defmodule Safira.Minigames do defp broadcast_coin_flip_rooms_update(action, value) do Phoenix.PubSub.broadcast(@pubsub, coin_flip_rooms_topic(), {action, value}) end + + @doc """ + Returns the list of slots_reel_icons. + + ## Examples + + iex> list_slots_reel_icons() + [%SlotsReelIcon{}, ...] + + """ + def list_slots_reel_icons do + Repo.all(SlotsReelIcon) + end + + @doc """ + Gets a single slots_reel_icon. + + Raises `Ecto.NoResultsError` if the Slots reel does not exist. + + ## Examples + + iex> get_slots_reel_icon!(123) + %SlotsReelIcon{} + + iex> get_slots_reel_icon!(456) + ** (Ecto.NoResultsError) + + """ + def get_slots_reel_icon!(id), do: Repo.get!(SlotsReelIcon, id) + + @doc """ + Creates a slots_reel_icon. + + ## Examples + + iex> create_slots_reel_icon(%{field: value}) + {:ok, %SlotsReelIcon{}} + + iex> create_slots_reel_icon(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_slots_reel_icon(attrs \\ %{}) do + %SlotsReelIcon{} + |> SlotsReelIcon.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a slots_reel_icon. + + ## Examples + + iex> update_slots_reel_icon(slots_reel_icon, %{field: new_value}) + {:ok, %SlotsReelIcon{}} + + iex> update_slots_reel_icon(slots_reel_icon, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_slots_reel_icon(%SlotsReelIcon{} = slots_reel_icon, attrs) do + slots_reel_icon + |> SlotsReelIcon.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a slots_reel_icon. + + ## Examples + + iex> delete_slots_reel_icon(slots_reel_icon) + {:ok, %SlotsReelIcon{}} + + iex> delete_slots_reel_icon(slots_reel_icon) + {:error, %Ecto.Changeset{}} + + """ + def delete_slots_reel_icon(%SlotsReelIcon{} = slots_reel_icon) do + Repo.delete(slots_reel_icon) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking slots_reel_icon changes. + + ## Examples + + iex> change_slots_reel_icon(slots_reel_icon) + %Ecto.Changeset{data: %SlotsReelIcon{}} + + """ + def change_slots_reel_icon(%SlotsReelIcon{} = slots_reel_icon, attrs \\ %{}) do + SlotsReelIcon.changeset(slots_reel_icon, attrs) + end + + @doc """ + Updates a slots reel image. + + ## Examples + + iex> update_slots_reel_icon_image(slots_reel_icon, %{image: image}) + {:ok, %SlotsReelIcon{}} + + iex> update_slots_reel_icon_image(slots_reel_icon, %{image: bad_image}) + {:error, %Ecto.Changeset{}} + + """ + def update_slots_reel_icon_image(%SlotsReelIcon{} = slots_reel_icon, attrs) do + slots_reel_icon + |> SlotsReelIcon.image_changeset(attrs) + |> Repo.update() + end + + @doc """ + Returns the list of slots_paytables. + + ## Examples + + iex> list_slots_paytables() + [%SlotsPaytable{}, ...] + + """ + def list_slots_paytables do + Repo.all(SlotsPaytable) + end + + @doc """ + Gets a single slots_paytable. + + Raises `Ecto.NoResultsError` if the Slots paytable does not exist. + + ## Examples + + iex> get_slots_paytable!(123) + %SlotsPaytable{} + + iex> get_slots_paytable!(456) + ** (Ecto.NoResultsError) + + """ + def get_slots_paytable!(id), do: Repo.get!(SlotsPaytable, id) + + @doc """ + Creates a slots_paytable. + + ## Examples + + iex> create_slots_paytable(%{field: value}) + {:ok, %SlotsPaytable{}} + + iex> create_slots_paytable(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_slots_paytable(attrs \\ %{}) do + %SlotsPaytable{} + |> SlotsPaytable.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a slots_paytable. + + ## Examples + + iex> update_slots_paytable(slots_paytable, %{field: new_value}) + {:ok, %SlotsPaytable{}} + + iex> update_slots_paytable(slots_paytable, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_slots_paytable(%SlotsPaytable{} = slots_paytable, attrs) do + slots_paytable + |> SlotsPaytable.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a slots_paytable. + + ## Examples + + iex> delete_slots_paytable(slots_paytable) + {:ok, %SlotsPaytable{}} + + iex> delete_slots_paytable(slots_paytable) + {:error, %Ecto.Changeset{}} + + """ + def delete_slots_paytable(%SlotsPaytable{} = slots_paytable) do + Repo.delete(slots_paytable) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking slots_paytable changes. + + ## Examples + + iex> change_slots_paytable(slots_paytable) + %Ecto.Changeset{data: %SlotsPaytable{}} + + """ + def change_slots_paytable(%SlotsPaytable{} = slots_paytable, attrs \\ %{}) do + SlotsPaytable.changeset(slots_paytable, attrs) + end end diff --git a/lib/safira/minigames/slots_paytable.ex b/lib/safira/minigames/slots_paytable.ex new file mode 100644 index 000000000..611ca9cdb --- /dev/null +++ b/lib/safira/minigames/slots_paytable.ex @@ -0,0 +1,23 @@ +defmodule Safira.Minigames.SlotsPaytable do + @moduledoc """ + Slots paytable. + """ + use Ecto.Schema + import Ecto.Changeset + + schema "slots_paytables" do + field :multiplier, :integer + field :position_figure_0, :integer + field :position_figure_1, :integer + field :position_figure_2, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(slots_paytable, attrs) do + slots_paytable + |> cast(attrs, [:multiplier, :position_figure_0, :position_figure_1, :position_figure_2]) + |> validate_required([:multiplier, :position_figure_0, :position_figure_1, :position_figure_2]) + end +end diff --git a/lib/safira/minigames/slots_reel.ex b/lib/safira/minigames/slots_reel.ex new file mode 100644 index 000000000..3b0dce543 --- /dev/null +++ b/lib/safira/minigames/slots_reel.ex @@ -0,0 +1,33 @@ +defmodule Safira.Minigames.SlotsReelIcon do + @moduledoc """ + Slots reel icon. + """ + use Safira.Schema + + import Ecto.Changeset + + @required_fields ~w(reel_0_index reel_1_index reel_2_index)a + @optional_fields ~w(image)a + + schema "slots_reel_icons" do + field :image, Uploaders.SlotsReelIcon.Type + field :reel_0_index, :integer + field :reel_1_index, :integer + field :reel_2_index, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(slots_reel_icon, attrs) do + slots_reel_icon + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + end + + @doc false + def image_changeset(slots_reel_icon, attrs) do + slots_reel_icon + |> cast_attachments(attrs, [:image]) + end +end diff --git a/lib/safira/uploaders/slots_reel.ex b/lib/safira/uploaders/slots_reel.ex new file mode 100644 index 000000000..f72d89aef --- /dev/null +++ b/lib/safira/uploaders/slots_reel.ex @@ -0,0 +1,28 @@ +defmodule Safira.Uploaders.SlotsReelIcon do + @moduledoc """ + Slots reel image uploader. + """ + use Safira.Uploader + + alias Safira.Minigames.SlotsReelIcon + + @versions [:original] + @extension_whitelist ~w(.svg) + + def validate({file, _}) do + file_extension = file.file_name |> Path.extname() |> String.downcase() + Enum.member?(extension_whitelist(), file_extension) + end + + def storage_dir(_, {_file, %SlotsReelIcon{} = speaker}) do + "uploads/slots_reel_icons/#{speaker.id}" + end + + def filename(version, _) do + version + end + + def extension_whitelist do + @extension_whitelist + end +end diff --git a/lib/safira_web/components/image_uploader.ex b/lib/safira_web/components/image_uploader.ex index 606c71f33..2a172625b 100644 --- a/lib/safira_web/components/image_uploader.ex +++ b/lib/safira_web/components/image_uploader.ex @@ -9,6 +9,7 @@ defmodule SafiraWeb.Components.ImageUploader do attr :image_class, :string, default: "" attr :image, :string, default: nil attr :icon, :string, default: "hero-photo" + attr :preview_disabled, :boolean, default: false def image_uploader(assigns) do ~H""" @@ -32,15 +33,17 @@ defmodule SafiraWeb.Components.ImageUploader do <% end %> - <%= for entry <- @upload.entries do %> -
-
- <.live_img_preview class={"p-4 #{@image_class}"} entry={entry} /> -
- <%= for err <- upload_errors(@upload, entry) do %> -

<%= Phoenix.Naming.humanize(err) %>

- <% end %> -
+ <%= if !@preview_disabled do %> + <%= for entry <- @upload.entries do %> +
+
+ <.live_img_preview class={"p-4 #{@image_class}"} entry={entry} /> +
+ <%= for err <- upload_errors(@upload, entry) do %> +

<%= Phoenix.Naming.humanize(err) %>

+ <% end %> +
+ <% end %> <% end %> <%= for err <- upload_errors(@upload) do %>

<%= Phoenix.Naming.humanize(err) %>

diff --git a/lib/safira_web/config.ex b/lib/safira_web/config.ex index 915a68a39..a61082e78 100644 --- a/lib/safira_web/config.ex +++ b/lib/safira_web/config.ex @@ -51,6 +51,12 @@ defmodule SafiraWeb.Config do icon: "hero-circle-stack", url: "/app/coin_flip" }, + %{ + key: :slots, + title: "Slots", + icon: "hero-circle-stack", + url: "/app/slots" + }, %{ key: :leaderboard, title: "Leaderboard", diff --git a/lib/safira_web/live/app/slots_live/components/machine.ex b/lib/safira_web/live/app/slots_live/components/machine.ex new file mode 100644 index 000000000..d01efe3ed --- /dev/null +++ b/lib/safira_web/live/app/slots_live/components/machine.ex @@ -0,0 +1,77 @@ +defmodule SafiraWeb.App.SlotsLive.Components.Machine do + @moduledoc """ + Slots machine component. + """ + use SafiraWeb, :component + + alias Safira.Minigames + alias Safira.Uploaders.SlotsReelIcon + + def machine(assigns) do + reels = Minigames.list_slots_reel_icons() + reels_by_position = organize_reels_by_position(reels) + + assigns = + assigns + |> assign(:reels_by_position, reels_by_position) + |> assign(:reel_height, calculate_height(reels_by_position)) + + ~H""" +
+
+
+ <%= for reel_num <- 0..2 do %> +
+ <% end %> +
+
+
+ """ + end + + defp calculate_height(reels_by_position) do + # Get length of first reel (they should all be same length) + {_reel_num, reel_images} = Enum.at(reels_by_position, 0) + length(reel_images) * 79 + end + + defp organize_reels_by_position(reels) do + reels + |> Enum.reduce(%{0 => [], 1 => [], 2 => []}, fn reel, acc -> + acc + |> Map.update!(0, &[{reel, reel.reel_0_index} | &1]) + |> Map.update!(1, &[{reel, reel.reel_1_index} | &1]) + |> Map.update!(2, &[{reel, reel.reel_2_index} | &1]) + end) + |> Map.new(fn {k, v} -> + {k, v |> Enum.filter(fn {_, index} -> index != -1 end) |> Enum.sort_by(&elem(&1, 1))} + end) + end + + defp build_reel_background(reel_images) do + urls = + reel_images + |> Enum.sort_by(&elem(&1, 1)) + |> Enum.map_join(", ", fn {reel, _} -> + url = SlotsReelIcon.url({reel.image, reel}, :original, signed: true) + "url('#{url}')" + end) + + urls + end + + defp build_background_positions(reel_images) do + reel_images + |> Enum.sort_by(&elem(&1, 1)) + |> Enum.with_index() + |> Enum.map_join(", ", fn {_reel, index} -> + position = index * 79 + "#{position}px" + end) + end +end diff --git a/lib/safira_web/live/app/slots_live/components/result_modal.ex b/lib/safira_web/live/app/slots_live/components/result_modal.ex new file mode 100644 index 000000000..c3c84e0be --- /dev/null +++ b/lib/safira_web/live/app/slots_live/components/result_modal.ex @@ -0,0 +1,118 @@ +defmodule SafiraWeb.App.SlotsLive.Components.ResultModal do + @moduledoc """ + Lucky wheel drop result modal component. + """ + use SafiraWeb, :component + + attr :id, :string, required: true + attr :drop_type, :atom, required: true + attr :drop, :map, required: true + attr :show, :boolean, default: false + attr :text, :string, default: "" + attr :wrapper_class, :string, default: "" + attr :on_cancel, JS, default: %JS{} + attr :content_class, :string, default: "bg-primary" + attr :show_vault_link, :boolean, default: true + + def result_modal(assigns) do + ~H""" + @@ -50,6 +60,20 @@ /> +<.modal + :if={@live_action in [:edit_slots]} + id="coin-flip-config-modal" + wrapper_class="p-3" + show + on_cancel={JS.patch(~p"/dashboard/minigames/")} +> + <.live_component + id="slots-configurator" + module={SafiraWeb.Backoffice.MinigamesLive.Slots.FormComponent} + patch={~p"/dashboard/minigames/"} + /> + + <.modal :if={@live_action in [:edit_wheel_drops]} id="wheel-drops-modal" @@ -67,12 +91,40 @@ :if={@live_action in [:simulate_wheel]} id="wheel-simulate-modal" show - on_cancel={JS.patch(~p"/dashboard/minigames/wheel/drops")} + on_cancel={JS.patch(~p"/dashboard/minigames/slots")} > <.live_component id="wheel-configurator" module={SafiraWeb.Backoffice.MinigamesLive.Simulator.Index} current_user={@current_user} - patch={~p"/dashboard/minigames/wheel/drops"} + patch={~p"/dashboard/minigames/slots"} + /> + + +<.modal + :if={@live_action in [:edit_slots_reel_icons_icons]} + id="wheel-simulate-modal" + show + on_cancel={JS.patch(~p"/dashboard/minigames/slots")} +> + <.live_component + id="wheel-configurator" + module={SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent} + current_user={@current_user} + patch={~p"/dashboard/minigames/slots"} + /> + + +<.modal + :if={@live_action in [:edit_slots_reel_icons_position]} + id="wheel-simulate-modal" + show + on_cancel={JS.patch(~p"/dashboard/minigames/slots")} +> + <.live_component + id="wheel-configurator" + module={SafiraWeb.Backoffice.MinigamesLive.ReelsPosition.FormComponent} + current_user={@current_user} + patch={~p"/dashboard/minigames/slots"} /> diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex new file mode 100644 index 000000000..1769fd4f4 --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex @@ -0,0 +1,106 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.Slots.FormComponent do + @moduledoc false + use SafiraWeb, :live_component + + import SafiraWeb.Components.Forms + + alias Ecto.Changeset + alias Safira.Minigames + + def render(assigns) do + ~H""" +
+ <.page + title={gettext("Slots Configuration")} + subtitle={gettext("Configures slots minigame's internal settings.")} + > +
+ <.form + id="slots-config-form" + for={@form} + phx-submit="save" + phx-change="validate" + phx-target={@myself} + > +
+ <.field + field={@form[:is_active]} + name="is_active" + label="Active" + type="switch" + help_text={gettext("Defines whether the slots minigame is active.")} + wrapper_class="my-6" + /> +
+
+ <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %> +
+ +
+ <.link + patch={~p"/dashboard/minigames/slots/reels_icons"} + class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit reels icons") %> + + + <.link + patch={~p"/dashboard/minigames/slots/reels_position"} + class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit reels position") %> + + + <.link + patch={~p"/dashboard/minigames/slots/paytable"} + class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit paytable") %> + +
+
+ +
+ """ + end + + def mount(socket) do + {:ok, + socket + |> assign( + form: + to_form( + %{ + "is_active" => Minigames.coin_flip_active?() + }, + as: :coin_flip_configuration + ) + )} + end + + def handle_event("validate", params, socket) do + changeset = validate_configuration(params["is_active"]) + + {:noreply, + assign(socket, form: to_form(changeset, action: :validate, as: :wheel_configuration))} + end + + def handle_event("save", params, socket) do + if valid_config?(params) do + Minigames.change_coin_flip_active("true" == params["is_active"]) + {:noreply, socket |> push_patch(to: ~p"/dashboard/minigames/")} + else + {:noreply, socket} + end + end + + defp validate_configuration(is_active) do + {%{}, %{is_active: :boolean}} + |> Changeset.cast(%{is_active: is_active}, [:is_active]) + end + + defp valid_config?(params) do + validation = validate_configuration(params["is_active"]) + validation.errors == [] + end +end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex new file mode 100644 index 000000000..7c85141c5 --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex @@ -0,0 +1,99 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.Paytable.FormComponent do + @moduledoc false + use SafiraWeb, :live_component + + import SafiraWeb.Components.Forms + + alias Ecto.Changeset + alias Safira.Minigames + + def render(assigns) do + ~H""" +
+ <.page + title={gettext("Slots Configuration")} + subtitle={gettext("Configures slots minigame's internal settings.")} + > +
+ <.form + id="slots-config-form" + for={@form} + phx-submit="save" + phx-change="validate" + phx-target={@myself} + > +
+ <.field + field={@form[:is_active]} + name="is_active" + label="Active" + type="switch" + help_text={gettext("Defines whether the slots minigame is active.")} + wrapper_class="my-6" + /> +
+
+ <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %> +
+ +
+ <.link + patch={~p"/dashboard/minigames/slots"} + class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit reels") %> + + + <.link + patch={~p"/dashboard/minigames/slots"} + class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit paytable") %> + +
+
+ +
+ """ + end + + def mount(socket) do + {:ok, + socket + |> assign( + form: + to_form( + %{ + "is_active" => Minigames.coin_flip_active?() + }, + as: :coin_flip_configuration + ) + )} + end + + def handle_event("validate", params, socket) do + changeset = validate_configuration(params["is_active"]) + + {:noreply, + assign(socket, form: to_form(changeset, action: :validate, as: :wheel_configuration))} + end + + def handle_event("save", params, socket) do + if valid_config?(params) do + Minigames.change_coin_flip_active("true" == params["is_active"]) + {:noreply, socket |> push_patch(to: ~p"/dashboard/minigames/")} + else + {:noreply, socket} + end + end + + defp validate_configuration(is_active) do + {%{}, %{is_active: :boolean}} + |> Changeset.cast(%{is_active: is_active}, [:is_active]) + end + + defp valid_config?(params) do + validation = validate_configuration(params["is_active"]) + validation.errors == [] + end +end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex new file mode 100644 index 000000000..68c9c55b8 --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex @@ -0,0 +1,214 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent do + @moduledoc false + alias Safira.Minigames.SlotsReelIcon + use SafiraWeb, :live_component + + import SafiraWeb.Components.Forms + import SafiraWeb.Components.ImageUploader + + alias Safira.Minigames + + def render(assigns) do + ~H""" +
+ <.page title={gettext("Reels Configuration")} subtitle={gettext("Configures slots reels.")}> +
+ <.simple_form + id="slots-reels-config-form" + for={@form} + phx-submit="save" + phx-change="validate" + phx-target={@myself} + > + <%!--
+ <.field_label>Number of icons + <.input + field={@form[:number_of_icons]} + name="number_of_icons" + type="number" + min="1" + value={@number_of_icons} + phx-debounce="blur" + /> +
--%> +
+ <.field_label>Upload Images + <.image_uploader + class="size-32 border-2 border-dashed" + upload={@uploads.images} + preview_disabled + icon="hero-photo" + /> +
+ <%= for entry <- @uploads.images.entries do %> +
+ <.live_img_preview entry={entry} class="size-32 object-cover rounded-lg" /> +
+ +
+ <%= for err <- upload_errors(@uploads.images, entry) do %> +
<%= err %>
+ <% end %> +
+ <% end %> + <%= if Enum.empty?(@uploads.images.entries) do %> +
+ No images uploaded +
+ <% end %> + <%!-- + <%= for i <- length(@uploads.images.entries)..(@number_of_icons - 1) do %> +
+ Empty slot <%= i + 1 %> +
+ <% end %> --%> +
+
+

+ <%= gettext("Number of icons: %{num_icons}", num_icons: length(@uploads.images.entries)) %> +

+
+ <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %> +
+ +
+ +
+ """ + end + + def mount(socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> assign(:reel, %SlotsReelIcon{}) + |> allow_upload(:images, + accept: Uploaders.SlotsReelIcon.extension_whitelist(), + max_entries: 10 + ) + |> assign(form: to_form(%{}))} + end + + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Minigames.change_slots_reel_icon(socket.assigns.reel)) + end)} + end + + def handle_event("validate", %{"number_of_icons" => number_of_icons}, socket) do + number = max(String.to_integer(number_of_icons), 1) + changeset = Minigames.change_slots_reel_icon(socket.assigns.reel, %{number_of_icons: number}) + + {:noreply, + socket + |> assign(:number_of_icons, number) + |> assign(form: to_form(changeset, action: :validate))} + end + + def handle_event("validate", params, socket) do + changeset = Minigames.change_slots_reel_icon(socket.assigns.reel, params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + # Update save handler to use new consume_image_data + def handle_event("save", _params, socket) do + case consume_image_data(socket) do + {:ok, _results} -> + {:noreply, + socket + |> put_flash(:info, "Reels created successfully") + |> push_patch(to: ~p"/dashboard/minigames/slots")} + + {:error, reason} -> + {:noreply, + socket + |> put_flash(:error, reason)} + end + end + + def handle_event("cancel-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :images, ref)} + end + + defp consume_image_data(socket) do + existing_reels = Minigames.list_slots_reel_icons() + + Ecto.Multi.new() + |> delete_existing_reels(existing_reels) + |> create_new_reels(socket) + |> Safira.Repo.transaction() + |> handle_transaction_result() + end + + defp delete_existing_reels(multi, reels) do + Enum.reduce(reels, multi, fn reel, multi -> + Ecto.Multi.delete(multi, {:delete_reel, reel.id}, reel) + end) + end + + defp create_new_reels(multi, socket) do + Ecto.Multi.run(multi, :create_reels, fn _repo, _changes -> + results = + socket.assigns.uploads.images.entries + |> Enum.with_index() + |> Enum.map(fn {entry, index} -> + create_reel_with_image(socket, entry, index) + end) + |> Enum.map(fn + # Extract successful results + {:ok, result} -> result + error -> error + end) + + if Enum.all?(results, &is_struct(&1, Safira.Minigames.SlotsReelIcon)) do + {:ok, results} + else + {:error, "Failed to create some reels"} + end + end) + end + + defp create_reel_with_image(socket, entry, index) do + consume_uploaded_entry(socket, entry, fn %{path: path} -> + Minigames.create_slots_reel_icon(%{ + "reel_0_index" => index, + "reel_1_index" => index, + "reel_2_index" => index + }) + |> case do + {:ok, reel} -> + Minigames.update_slots_reel_icon_image(reel, %{ + "image" => %Plug.Upload{ + content_type: entry.client_type, + filename: entry.client_name, + path: path + } + }) + + error -> + error + end + end) + end + + defp handle_transaction_result(transaction_result) do + case transaction_result do + {:ok, %{create_reels: results}} -> + {:ok, results} + + {:error, _failed_operation, error, _changes} -> + {:error, "Failed to update reels: #{inspect(error)}"} + end + end +end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex new file mode 100644 index 000000000..34615653d --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex @@ -0,0 +1,265 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsPosition.FormComponent do + @moduledoc false + alias Safira.Minigames.SlotsReelIcon + use SafiraWeb, :live_component + + alias Safira.Minigames + + @impl true + def render(assigns) do + ~H""" +
+ <.page + title={gettext("Reels Position Configuration")} + subtitle={gettext("Configures slots reels.")} + > +
+
+
+
+ Icon +
+ +
+
+
+
+
+

Drag and drop the icons to change their order

+

Number of icons: <%= length(@slots_icons_per_column[0]) %>

+

Number of icons: <%= length(@slots_icons_per_column[1]) %>

+

Number of icons: <%= length(@slots_icons_per_column[2]) %>

+
+
+ <.button phx-click="save" phx-target={@myself} phx-disable-with="Saving..."> + <%= gettext("Save Configuration") %> + +
+
+ +
+ """ + end + + @impl true + def mount(socket) do + slots_reel_icons = Minigames.list_slots_reel_icons() + + reel_order = %{ + "reel-0" => initialize_reel_order(slots_reel_icons, :reel_0_index), + "reel-1" => initialize_reel_order(slots_reel_icons, :reel_1_index), + "reel-2" => initialize_reel_order(slots_reel_icons, :reel_2_index) + } + + visibility = %{ + 0 => + Enum.reduce(slots_reel_icons, %{}, fn icon, acc -> + Map.put(acc, icon.id, Map.get(icon, :reel_0_index) != -1) + end), + 1 => + Enum.reduce(slots_reel_icons, %{}, fn icon, acc -> + Map.put(acc, icon.id, Map.get(icon, :reel_1_index) != -1) + end), + 2 => + Enum.reduce(slots_reel_icons, %{}, fn icon, acc -> + Map.put(acc, icon.id, Map.get(icon, :reel_2_index) != -1) + end) + } + + {:ok, + socket + |> assign(:slots_reel_icons, slots_reel_icons) + |> assign(:slots_icons_per_column, sort_reels_for_column(slots_reel_icons)) + |> assign(:reel, %SlotsReelIcon{}) + |> assign(:reel_order, reel_order) + |> assign(:visibility, visibility)} + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns)} + end + + @impl true + def handle_event("update-sorting", %{"ids" => ids}, socket) do + reel_order = + Enum.reduce(ids, socket.assigns.reel_order, fn id, acc -> + [_reel, reel_index, _reel_id] = String.split(id, "-", parts: 3) + + Map.update!(acc, "reel-#{reel_index}", fn _order -> + Enum.with_index(ids) + |> Enum.reduce(%{}, fn {id, index}, order_acc -> + [_reel, _reel_index, reel_id] = String.split(id, "-", parts: 3) + Map.put(order_acc, reel_id, index) + end) + end) + end) + + slots_icons_per_column = + update_slots_icons_per_column(socket.assigns.slots_reel_icons, reel_order) + + {:noreply, + socket + |> assign(:reel_order, reel_order) + |> assign(:slots_icons_per_column, slots_icons_per_column)} + end + + @impl true + def handle_event("save", _params, socket) do + case save_reel_order(socket) do + {:ok, _results} -> + {:noreply, + socket + |> put_flash(:info, "Reel configuration saved successfully") + |> push_patch(to: ~p"/dashboard/minigames/slots")} + + {:error, reason} -> + {:noreply, + socket + |> put_flash(:error, reason)} + end + end + + @impl true + def handle_event("toggle-reel", %{"reel-icon" => reel_icon, "reel" => reel}, socket) do + [_, reel_num] = String.split(reel, "-", parts: 2) + reel_num = String.to_integer(reel_num) + + visibility = + Map.update!(socket.assigns.visibility, reel_num, fn reel_map -> + Map.update!(reel_map, reel_icon, &(!&1)) + end) + + {:noreply, socket |> assign(:visibility, visibility)} + end + + defp save_reel_order(socket) do + reel_order = socket.assigns.reel_order + visibility = socket.assigns.visibility + + Ecto.Multi.new() + |> update_reel_order(reel_order["reel-0"], visibility[0], :reel_0_index) + |> update_reel_order(reel_order["reel-1"], visibility[1], :reel_1_index) + |> update_reel_order(reel_order["reel-2"], visibility[2], :reel_2_index) + |> Safira.Repo.transaction() + |> handle_transaction_result() + end + + defp update_reel_order(multi, reel_order, visibility, reel_index_field) do + visible_reel_order = Enum.filter(reel_order, fn {id, _index} -> visibility[id] end) + hidden_reel_order = Enum.filter(reel_order, fn {id, _index} -> not visibility[id] end) + + recalculated_reel_order = + visible_reel_order + |> Enum.sort_by(fn {_id, index} -> index end) + |> Enum.with_index() + |> Enum.reduce(%{}, fn {{id, _}, index}, acc -> Map.put(acc, id, index) end) + + final_reel_order = + Enum.reduce(hidden_reel_order, recalculated_reel_order, fn {id, _}, acc -> + Map.put(acc, id, -1) + end) + + Enum.reduce(final_reel_order, multi, fn {id, index}, multi -> + case Minigames.get_slots_reel_icon!(id) do + nil -> + multi + + reel -> + Ecto.Multi.update( + multi, + {:update_reel, reel_index_field, id}, + Minigames.change_slots_reel_icon(reel, %{reel_index_field => index}) + ) + end + end) + end + + defp handle_transaction_result(transaction_result) do + case transaction_result do + {:ok, results} -> + {:ok, results} + + {:error, _failed_operation, error, _changes} -> + {:error, "Failed to update reels: #{inspect(error)}"} + end + end + + defp sort_reels_for_column(reels) do + %{ + 0 => sort_reels_for_column_by_index(reels, 0), + 1 => sort_reels_for_column_by_index(reels, 1), + 2 => sort_reels_for_column_by_index(reels, 2) + } + end + + defp sort_reels_for_column_by_index(reels, column_index) do + index_field = String.to_existing_atom("reel_#{column_index}_index") + + {hidden, visible} = Enum.split_with(reels, &(Map.get(&1, index_field) == -1)) + + visible = + visible + |> Enum.sort_by(&Map.get(&1, index_field)) + + visible ++ hidden + end + + defp initialize_reel_order(slots_reel_icons, index_field) do + {hidden, visible} = Enum.split_with(slots_reel_icons, &(Map.get(&1, index_field) == -1)) + + visible_order = + visible + |> Enum.sort_by(&Map.get(&1, index_field)) + |> Enum.with_index() + |> Enum.reduce(%{}, fn {icon, index}, acc -> Map.put(acc, to_string(icon.id), index) end) + + hidden_order = + hidden + |> Enum.with_index(Enum.count(visible_order)) + |> Enum.reduce(%{}, fn {icon, index}, acc -> Map.put(acc, to_string(icon.id), index) end) + + Map.merge(visible_order, hidden_order) + end + + defp update_slots_icons_per_column(slots_reel_icons, reel_order) do + %{ + 0 => update_slots_icons_per_column_by_index(slots_reel_icons, reel_order["reel-0"]), + 1 => update_slots_icons_per_column_by_index(slots_reel_icons, reel_order["reel-1"]), + 2 => update_slots_icons_per_column_by_index(slots_reel_icons, reel_order["reel-2"]) + } + end + + defp update_slots_icons_per_column_by_index(slots_reel_icons, reel_order) do + slots_reel_icons + |> Enum.filter(fn icon -> Map.has_key?(reel_order, icon.id) end) + |> Enum.sort_by(fn icon -> reel_order[icon.id] end) + end +end diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index 991d1ea29..74801156e 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -104,6 +104,8 @@ defmodule SafiraWeb.Router do live "/coin_flip", CoinFlipLive.Index, :index + live "/slots", SlotsLive.Index, :index + scope "/store", StoreLive do live "/", Index, :index live "/product/:id", Show, :show @@ -222,6 +224,11 @@ defmodule SafiraWeb.Router do live "/wheel", MinigamesLive.Index, :edit_wheel live "/coin_flip", MinigamesLive.Index, :edit_coin_flip + + live "/slots", MinigamesLive.Index, :edit_slots + live "/slots/reels_icons", MinigamesLive.Index, :edit_slots_reel_icons_icons + live "/slots/reels_position", MinigamesLive.Index, :edit_slots_reel_icons_position + live "/slots/paytable", MinigamesLive.Index, :edit_slots_paytable end live "/scanner", ScannerLive.Index, :index diff --git a/priv/repo/migrations/20250121020351_create_slots_reel_icons.exs b/priv/repo/migrations/20250121020351_create_slots_reel_icons.exs new file mode 100644 index 000000000..81e30e166 --- /dev/null +++ b/priv/repo/migrations/20250121020351_create_slots_reel_icons.exs @@ -0,0 +1,15 @@ +defmodule Safira.Repo.Migrations.CreateSlotsReelIcons do + use Ecto.Migration + + def change do + create table(:slots_reel_icons, primary_key: false) do + add :id, :binary_id, primary_key: true + add :image, :string + add :reel_0_index, :integer + add :reel_1_index, :integer + add :reel_2_index, :integer + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20250121025339_create_slots_paytables.exs b/priv/repo/migrations/20250121025339_create_slots_paytables.exs new file mode 100644 index 000000000..824cb0a09 --- /dev/null +++ b/priv/repo/migrations/20250121025339_create_slots_paytables.exs @@ -0,0 +1,14 @@ +defmodule Safira.Repo.Migrations.CreateSlotsPaytables do + use Ecto.Migration + + def change do + create table(:slots_paytables) do + add :multiplier, :integer + add :position_figure_0, :integer + add :position_figure_1, :integer + add :position_figure_2, :integer + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test/safira/minigames_test.exs b/test/safira/minigames_test.exs index 6359da2c4..4e6849a05 100644 --- a/test/safira/minigames_test.exs +++ b/test/safira/minigames_test.exs @@ -246,4 +246,166 @@ defmodule Safira.MinigamesTest do Minigames.create_coin_flip_room(attrs) end end + + describe "slots_reel_icons" do + alias Safira.Minigames.SlotsReelIcon + + import Safira.MinigamesFixtures + + @invalid_attrs %{image: nil, reel_0_index: nil, reel_1_index: nil, reel_2_index: nil} + + test "list_slots_reel_icons/0 returns all slots_reel_icons" do + slots_reel_icon = slots_reel_icon_fixture() + assert Minigames.list_slots_reel_icons() == [slots_reel_icon] + end + + test "get_slots_reel_icon!/1 returns the slots_reel_icon with given id" do + slots_reel_icon = slots_reel_icon_fixture() + assert Minigames.get_slots_reel_icon!(slots_reel_icon.id) == slots_reel_icon + end + + test "create_slots_reel_icon/1 with valid data creates a slots_reel_icon" do + valid_attrs = %{image: "some image", reel_0_index: 42, reel_1_index: 42, reel_2_index: 42} + + assert {:ok, %SlotsReelIcon{} = slots_reel_icon} = + Minigames.create_slots_reel_icon(valid_attrs) + + assert slots_reel_icon.image == "some image" + assert slots_reel_icon.reel_0_index == 42 + assert slots_reel_icon.reel_1_index == 42 + assert slots_reel_icon.reel_2_index == 42 + end + + test "create_slots_reel_icon/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Minigames.create_slots_reel_icon(@invalid_attrs) + end + + test "update_slots_reel_icon/2 with valid data updates the slots_reel_icon" do + slots_reel_icon = slots_reel_icon_fixture() + + update_attrs = %{ + image: "some updated image", + reel_0_index: 43, + reel_1_index: 43, + reel_2_index: 43 + } + + assert {:ok, %SlotsReelIcon{} = slots_reel_icon} = + Minigames.update_slots_reel_icon(slots_reel_icon, update_attrs) + + assert slots_reel_icon.image == "some updated image" + assert slots_reel_icon.reel_0_index == 43 + assert slots_reel_icon.reel_1_index == 43 + assert slots_reel_icon.reel_2_index == 43 + end + + test "update_slots_reel_icon/2 with invalid data returns error changeset" do + slots_reel_icon = slots_reel_icon_fixture() + + assert {:error, %Ecto.Changeset{}} = + Minigames.update_slots_reel_icon(slots_reel_icon, @invalid_attrs) + + assert slots_reel_icon == Minigames.get_slots_reel_icon!(slots_reel_icon.id) + end + + test "delete_slots_reel_icon/1 deletes the slots_reel_icon" do + slots_reel_icon = slots_reel_icon_fixture() + assert {:ok, %SlotsReelIcon{}} = Minigames.delete_slots_reel_icon(slots_reel_icon) + + assert_raise Ecto.NoResultsError, fn -> + Minigames.get_slots_reel_icon!(slots_reel_icon.id) + end + end + + test "change_slots_reel_icon/1 returns a slots_reel_icon changeset" do + slots_reel_icon = slots_reel_icon_fixture() + assert %Ecto.Changeset{} = Minigames.change_slots_reel_icon(slots_reel_icon) + end + end + + describe "slots_paytables" do + alias Safira.Minigames.SlotsPaytable + + import Safira.MinigamesFixtures + + @invalid_attrs %{ + multiplier: nil, + position_figure_0: nil, + position_figure_1: nil, + position_figure_2: nil + } + + test "list_slots_paytables/0 returns all slots_paytables" do + slots_paytable = slots_paytable_fixture() + assert Minigames.list_slots_paytables() == [slots_paytable] + end + + test "get_slots_paytable!/1 returns the slots_paytable with given id" do + slots_paytable = slots_paytable_fixture() + assert Minigames.get_slots_paytable!(slots_paytable.id) == slots_paytable + end + + test "create_slots_paytable/1 with valid data creates a slots_paytable" do + valid_attrs = %{ + multiplier: 42, + position_figure_0: 42, + position_figure_1: 42, + position_figure_2: 42 + } + + assert {:ok, %SlotsPaytable{} = slots_paytable} = + Minigames.create_slots_paytable(valid_attrs) + + assert slots_paytable.multiplier == 42 + assert slots_paytable.position_figure_0 == 42 + assert slots_paytable.position_figure_1 == 42 + assert slots_paytable.position_figure_2 == 42 + end + + test "create_slots_paytable/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Minigames.create_slots_paytable(@invalid_attrs) + end + + test "update_slots_paytable/2 with valid data updates the slots_paytable" do + slots_paytable = slots_paytable_fixture() + + update_attrs = %{ + multiplier: 43, + position_figure_0: 43, + position_figure_1: 43, + position_figure_2: 43 + } + + assert {:ok, %SlotsPaytable{} = slots_paytable} = + Minigames.update_slots_paytable(slots_paytable, update_attrs) + + assert slots_paytable.multiplier == 43 + assert slots_paytable.position_figure_0 == 43 + assert slots_paytable.position_figure_1 == 43 + assert slots_paytable.position_figure_2 == 43 + end + + test "update_slots_paytable/2 with invalid data returns error changeset" do + slots_paytable = slots_paytable_fixture() + + assert {:error, %Ecto.Changeset{}} = + Minigames.update_slots_paytable(slots_paytable, @invalid_attrs) + + assert slots_paytable == Minigames.get_slots_paytable!(slots_paytable.id) + end + + test "delete_slots_paytable/1 deletes the slots_paytable" do + slots_paytable = slots_paytable_fixture() + assert {:ok, %SlotsPaytable{}} = Minigames.delete_slots_paytable(slots_paytable) + + assert_raise Ecto.NoResultsError, fn -> + Minigames.get_slots_paytable!(slots_paytable.id) + end + end + + test "change_slots_paytable/1 returns a slots_paytable changeset" do + slots_paytable = slots_paytable_fixture() + assert %Ecto.Changeset{} = Minigames.change_slots_paytable(slots_paytable) + end + end end diff --git a/test/support/fixtures/minigames_fixtures.ex b/test/support/fixtures/minigames_fixtures.ex index faa6689dd..d8535562c 100644 --- a/test/support/fixtures/minigames_fixtures.ex +++ b/test/support/fixtures/minigames_fixtures.ex @@ -47,4 +47,38 @@ defmodule Safira.MinigamesFixtures do coin_flip_room end + + @doc """ + Generate a slots_reel_icon. + """ + def slots_reel_icon_fixture(attrs \\ %{}) do + {:ok, slots_reel_icon} = + attrs + |> Enum.into(%{ + image: "some image", + reel_0_index: 42, + reel_1_index: 42, + reel_2_index: 42 + }) + |> Safira.Minigames.create_slots_reel_icon() + + slots_reel_icon + end + + @doc """ + Generate a slots_paytable. + """ + def slots_paytable_fixture(attrs \\ %{}) do + {:ok, slots_paytable} = + attrs + |> Enum.into(%{ + position_figure_0: 42, + position_figure_1: 42, + position_figure_2: 42, + multiplier: 42 + }) + |> Safira.Minigames.create_slots_paytable() + + slots_paytable + end end From ddf1e158db91b833b91e059c559be76fd3dc31eb Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Tue, 28 Jan 2025 01:02:33 +0000 Subject: [PATCH 02/12] feat: add slots multiplier probabilities --- lib/safira/minigames.ex | 147 ++++++++++ lib/safira/minigames/slots_payline.ex | 24 ++ lib/safira/minigames/slots_paytable.ex | 16 +- lib/safira_web/live/app/slots_live/index.ex | 9 +- .../live/app/slots_live/index.html.heex | 2 +- .../backoffice/minigames_live/index.html.heex | 30 +- .../slots_live/form_component.ex | 21 +- .../slots_live/payline_live/form_component.ex | 256 ++++++++++++++++++ .../paytable_live/form_component.ex | 225 +++++++++++++++ .../playtable_live/form_component.ex | 99 ------- .../reels_icons_live/form_component.ex | 26 +- .../reels_position_live/form_component.ex | 2 +- lib/safira_web/router.ex | 1 + .../20250121025339_create_slots_paytables.exs | 7 +- .../20250127003102_create_slots_paylines.exs | 15 + test/safira/minigames_test.exs | 63 +++++ test/support/fixtures/minigames_fixtures.ex | 16 ++ 17 files changed, 826 insertions(+), 133 deletions(-) create mode 100644 lib/safira/minigames/slots_payline.ex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex delete mode 100644 lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex create mode 100644 priv/repo/migrations/20250127003102_create_slots_paylines.exs diff --git a/lib/safira/minigames.ex b/lib/safira/minigames.ex index 737ad0d72..e3887b9f5 100644 --- a/lib/safira/minigames.ex +++ b/lib/safira/minigames.ex @@ -1175,4 +1175,151 @@ defmodule Safira.Minigames do def change_slots_paytable(%SlotsPaytable{} = slots_paytable, attrs \\ %{}) do SlotsPaytable.changeset(slots_paytable, attrs) end + + @doc """ + Changes the slots active status. + + ## Examples + + iex> change_slots_active(true) + :ok + """ + def change_slots_active(active) do + Constants.set("slots_active_status", active) + broadcast_slots_config_update("is_active", active) + end + + @doc """ + Gets the slots active status. + + ## Examples + + iex> slots_active?() + true + """ + def slots_active? do + case Constants.get("slots_active_status") do + {:ok, active} -> + active + + {:error, _} -> + # If the active status is not set, set it to false by default + change_slots_active(true) + true + end + end + + @doc """ + Subscribes the caller to the slots' configuration updates. + + ## Examples + + iex> subscribe_to_slots_config_update() + :ok + """ + def subscribe_to_slots_config_update(config) do + Phoenix.PubSub.subscribe(@pubsub, slots_config_topic(config)) + end + + defp slots_config_topic(config), do: "slots:#{config}" + + defp broadcast_slots_config_update(config, value) do + Phoenix.PubSub.broadcast(@pubsub, slots_config_topic(config), {config, value}) + end + + alias Safira.Minigames.SlotsPayline + + @doc """ + Returns the list of slots_paylines. + + ## Examples + + iex> list_slots_paylines() + [%SlotsPayline{}, ...] + + """ + def list_slots_paylines do + Repo.all(SlotsPayline) + end + + @doc """ + Gets a single slots_payline. + + Raises `Ecto.NoResultsError` if the Slots payline does not exist. + + ## Examples + + iex> get_slots_payline!(123) + %SlotsPayline{} + + iex> get_slots_payline!(456) + ** (Ecto.NoResultsError) + + """ + def get_slots_payline!(id), do: Repo.get!(SlotsPayline, id) + + @doc """ + Creates a slots_payline. + + ## Examples + + iex> create_slots_payline(%{field: value}) + {:ok, %SlotsPayline{}} + + iex> create_slots_payline(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_slots_payline(attrs \\ %{}) do + %SlotsPayline{} + |> SlotsPayline.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a slots_payline. + + ## Examples + + iex> update_slots_payline(slots_payline, %{field: new_value}) + {:ok, %SlotsPayline{}} + + iex> update_slots_payline(slots_payline, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_slots_payline(%SlotsPayline{} = slots_payline, attrs) do + slots_payline + |> SlotsPayline.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a slots_payline. + + ## Examples + + iex> delete_slots_payline(slots_payline) + {:ok, %SlotsPayline{}} + + iex> delete_slots_payline(slots_payline) + {:error, %Ecto.Changeset{}} + + """ + def delete_slots_payline(%SlotsPayline{} = slots_payline) do + Repo.delete(slots_payline) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking slots_payline changes. + + ## Examples + + iex> change_slots_payline(slots_payline) + %Ecto.Changeset{data: %SlotsPayline{}} + + """ + def change_slots_payline(%SlotsPayline{} = slots_payline, attrs \\ %{}) do + SlotsPayline.changeset(slots_payline, attrs) + end end diff --git a/lib/safira/minigames/slots_payline.ex b/lib/safira/minigames/slots_payline.ex new file mode 100644 index 000000000..62b999685 --- /dev/null +++ b/lib/safira/minigames/slots_payline.ex @@ -0,0 +1,24 @@ +defmodule Safira.Minigames.SlotsPayline do + @moduledoc """ + Schema for slots payline that defines the positions of the slots. + Used to determine winning combinations and their payouts in the slots game. + """ + use Safira.Schema + + schema "slots_paylines" do + field :position_1, :integer + field :position_0, :integer + field :position_2, :integer + belongs_to :multiplier, Safira.Minigames.SlotsPaytable + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(slots_payline, attrs) do + slots_payline + |> cast(attrs, [:position_0, :position_1, :position_2]) + |> foreign_key_constraint(:multiplier_id) + |> validate_required([:position_0, :position_1, :position_2]) + end +end diff --git a/lib/safira/minigames/slots_paytable.ex b/lib/safira/minigames/slots_paytable.ex index 611ca9cdb..cc3116032 100644 --- a/lib/safira/minigames/slots_paytable.ex +++ b/lib/safira/minigames/slots_paytable.ex @@ -1,15 +1,13 @@ defmodule Safira.Minigames.SlotsPaytable do @moduledoc """ - Slots paytable. + Schema for slots paytable that defines multipliers and their probabilities. + Used to determine winning combinations and their payouts in the slots game. """ - use Ecto.Schema - import Ecto.Changeset + use Safira.Schema schema "slots_paytables" do field :multiplier, :integer - field :position_figure_0, :integer - field :position_figure_1, :integer - field :position_figure_2, :integer + field :probability, :float timestamps(type: :utc_datetime) end @@ -17,7 +15,9 @@ defmodule Safira.Minigames.SlotsPaytable do @doc false def changeset(slots_paytable, attrs) do slots_paytable - |> cast(attrs, [:multiplier, :position_figure_0, :position_figure_1, :position_figure_2]) - |> validate_required([:multiplier, :position_figure_0, :position_figure_1, :position_figure_2]) + |> cast(attrs, [:multiplier, :probability]) + |> validate_required([:multiplier, :probability]) + |> validate_number(:multiplier, greater_than: 0) + |> validate_number(:probability, greater_than_or_equal_to: 0, less_than_or_equal_to: 1) end end diff --git a/lib/safira_web/live/app/slots_live/index.ex b/lib/safira_web/live/app/slots_live/index.ex index 012be832b..54cc3790a 100644 --- a/lib/safira_web/live/app/slots_live/index.ex +++ b/lib/safira_web/live/app/slots_live/index.ex @@ -9,18 +9,17 @@ defmodule SafiraWeb.App.SlotsLive.Index do @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_slots_config_update("is_active") end {:ok, socket - |> assign(:current_page, :wheel) + |> assign(:current_page, :slots) |> assign(:in_spin?, false) |> 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(:slots_active?, Minigames.slots_active?())} end @impl true @@ -91,7 +90,7 @@ defmodule SafiraWeb.App.SlotsLive.Index do @impl true def handle_info({"is_active", value}, socket) do - {:noreply, socket |> assign(:wheel_active?, value)} + {:noreply, socket |> assign(:slots_active?, value)} end defp can_spin?(wheel_active?, tokens, price, in_spin?) do diff --git a/lib/safira_web/live/app/slots_live/index.html.heex b/lib/safira_web/live/app/slots_live/index.html.heex index e5d1e8331..48f730fd1 100644 --- a/lib/safira_web/live/app/slots_live/index.html.heex +++ b/lib/safira_web/live/app/slots_live/index.html.heex @@ -10,7 +10,7 @@ title={gettext("Spin")} subtitle={"💰 #{@wheel_price}"} class="w-64" - disabled={!can_spin?(@wheel_active?, @attendee_tokens, @wheel_price, @in_spin?)} + disabled={!can_spin?(@slots_active?, @attendee_tokens, @wheel_price, @in_spin?)} phx-click="spin" />
diff --git a/lib/safira_web/live/backoffice/minigames_live/index.html.heex b/lib/safira_web/live/backoffice/minigames_live/index.html.heex index 42a7e9b1e..364ee093a 100644 --- a/lib/safira_web/live/backoffice/minigames_live/index.html.heex +++ b/lib/safira_web/live/backoffice/minigames_live/index.html.heex @@ -109,7 +109,7 @@ > <.live_component id="wheel-configurator" - module={SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent} + module={SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent} current_user={@current_user} patch={~p"/dashboard/minigames/slots"} /> @@ -128,3 +128,31 @@ patch={~p"/dashboard/minigames/slots"} /> + +<.modal + :if={@live_action in [:edit_slots_paytable]} + id="wheel-simulate-modal" + show + on_cancel={JS.patch(~p"/dashboard/minigames/slots")} +> + <.live_component + id="wheel-configurator" + module={SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent} + current_user={@current_user} + patch={~p"/dashboard/minigames/slots"} + /> + + +<.modal + :if={@live_action in [:edit_slots_payline]} + id="wheel-simulate-modal" + show + on_cancel={JS.patch(~p"/dashboard/minigames/slots")} +> + <.live_component + id="wheel-configurator" + module={SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent} + current_user={@current_user} + patch={~p"/dashboard/minigames/slots"} + /> + diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex index 1769fd4f4..109e4693a 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/form_component.ex @@ -36,27 +36,34 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.Slots.FormComponent do <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %> -
+
<.link patch={~p"/dashboard/minigames/slots/reels_icons"} - class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + class="flex flex-col items-center justify-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" > <%= gettext("Edit reels icons") %> <.link patch={~p"/dashboard/minigames/slots/reels_position"} - class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + class="flex flex-col items-center justify-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" > <%= gettext("Edit reels position") %> <.link patch={~p"/dashboard/minigames/slots/paytable"} - class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + class="flex flex-col items-center justify-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" > <%= gettext("Edit paytable") %> + + <.link + patch={~p"/dashboard/minigames/slots/payline"} + class="flex flex-col items-center justify-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" + > + <%= gettext("Edit payline") %> +
@@ -71,9 +78,9 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.Slots.FormComponent do form: to_form( %{ - "is_active" => Minigames.coin_flip_active?() + "is_active" => Minigames.slots_active?() }, - as: :coin_flip_configuration + as: :slots_configuration ) )} end @@ -87,7 +94,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.Slots.FormComponent do def handle_event("save", params, socket) do if valid_config?(params) do - Minigames.change_coin_flip_active("true" == params["is_active"]) + Minigames.change_slots_active("true" == params["is_active"]) {:noreply, socket |> push_patch(to: ~p"/dashboard/minigames/")} else {:noreply, socket} diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex new file mode 100644 index 000000000..d1a4c5ac1 --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex @@ -0,0 +1,256 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do + @moduledoc false + alias Safira.Minigames.SlotsPayline + use SafiraWeb, :live_component + + alias Safira.Contest + alias Safira.Minigames + alias Safira.Minigames.WheelDrop + + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={gettext("Slots Paytable")} subtitle={gettext("Configures the slots paytable.")}> + <:actions> + <.link patch={~p"/dashboard/minigames/wheel/simulator"}> + <.button> + <.icon name="hero-play" class="w-5 h-5" /> + + + +
+
+

<%= gettext("Entries") %>

+ <.button phx-click={JS.push("add-entry", target: @myself)}> + <.icon name="hero-plus" class="w-5 h-5" /> + +
+
    + <%= for {id, type, _drop, form} <- @entries do %> +
  • + <.simple_form + id={id} + for={form} + phx-change="validate" + phx-target={@myself} + class="!mt-0" + > + <.field type="hidden" name="identifier" value={id} /> +
    + <%!-- <%= if type == :nil do %> + <.field + wrapper_class="col-span-4" + value={nil} + placeholder="Select drop type" + options={[ + {"Select a drop type", nil}, + {"Prize", :prize}, + {"Badge", :badge}, + {"Tokens", :tokens}, + {"Entries", :entries} + ]} + name="set_type" + label="Type" + type="select" + /> + <% else %> + <%= if type == :prize do %> --%> + <.field field={form[:position_0]} wrapper_class="col-span-1" type="number" /> + <.field field={form[:position_1]} wrapper_class="col-span-1" type="number" /> + <.field field={form[:position_2]} wrapper_class="col-span-1" type="number" /> + <.field field={form[:multiplier]} type="select" wrapper_class="col-span-2" /> + <%!-- <.field field={form[:probability]} type="number" wrapper_class="col-span-2" /> --%> + <.link + phx-click={JS.push("delete-entry", value: %{id: id})} + data-confirm="Are you sure?" + phx-target={@myself} + class="content-center px-3" + > + <.icon name="hero-trash" class="w-5 h-5" /> + +
    + +
  • + <% end %> +
+ +
+ <.field + class="col-span-2" + type="number" + name="probability" + label="Remaining probability" + value={@nothing_probability} + readonly + disabled + /> +
+
+
+ <.button phx-click="save" phx-target={@myself} phx-disable-with="Saving..."> + Save Configuration + +
+ +
+ """ + end + + @impl true + def mount(socket) do + # Load the wheel drops + entries = + Minigames.list_wheel_drops() + |> Enum.map(fn entry -> + {Ecto.UUID.generate(), Minigames.get_wheel_drop_type(entry), entry, + to_form(Minigames.change_wheel_drop(entry))} + end) + + {:ok, + socket + |> assign(entries: entries) + |> assign(nothing_probability: calculate_nothing_probability(entries)) + |> assign(prizes: Minigames.list_prizes()) + |> assign(badges: Contest.list_badges())} + end + + @impl true + def handle_event("add-entry", _, socket) do + entries = socket.assigns.entries + + # Add a new drop to the list + {:noreply, + socket + |> assign( + :entries, + entries ++ + [ + {Ecto.UUID.generate(), nil, %WheelDrop{}, + to_form(Minigames.change_wheel_drop(%WheelDrop{}))} + ] + )} + end + + @impl true + def handle_event("delete-entry", %{"id" => id}, socket) do + entries = socket.assigns.entries + # Find the drop to delete in the drops list + drop = Enum.find(entries, fn {drop_id, _, _, _} -> drop_id == id end) |> elem(2) + + # If the drop has an id, delete it from the database + if drop.id != nil do + Minigames.delete_wheel_drop(drop) + end + + # Remove the drop from the list + {:noreply, + socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _, _} -> drop_id == id end))} + end + + @impl true + def handle_event("validate", drop_params, socket) do + entries = socket.assigns.entries + entry = get_drop_data_by_id(entries, drop_params["identifier"]) + changeset = Minigames.change_wheel_drop(entry, drop_params["wheel_drop"]) + + # Update the form with the new changeset and the drop type if it changed + entries = + socket.assigns.entries + |> update_drop_type_form_if_type_changed(drop_params["identifier"], drop_params["set_type"]) + |> update_drop_form(drop_params["identifier"], to_form(changeset, action: :validate)) + + nothing_probability = calculate_nothing_probability(entries) + + {:noreply, + socket + |> assign(entries: entries) + |> assign(nothing_probability: nothing_probability)} + end + + @impl true + def handle_event("save", _params, socket) do + drops = socket.assigns.drops + + # Find if all the changesets are valid + valid_drops = + forms_valid?(Enum.map(drops, fn {_, _, _, form} -> form end)) and + calculate_nothing_probability(drops) >= 0 + + if valid_drops do + # For each drop, update or create it + Enum.each(drops, fn {_, _, drop, form} -> + if drop.id != nil do + Minigames.update_wheel_drop(drop, form.params) + else + Minigames.create_wheel_drop(form.params) + end + end) + + {:noreply, + socket + |> put_flash(:info, "Slots paytable changed successfully") + |> push_patch(to: socket.assigns.patch)} + else + {:noreply, socket} + end + end + + defp update_drop_type_form_if_type_changed(drops, id, type) when type not in [nil, ""] do + Enum.map(drops, fn + {^id, _, drop, form} -> {id, String.to_atom(type), drop, form} + other -> other + end) + end + + defp update_drop_type_form_if_type_changed(drops, _id, _type), do: drops + + defp update_drop_form(drops, id, new_form) do + Enum.map(drops, fn + {^id, drop_type, drop, _} -> {id, drop_type, drop, new_form} + other -> other + end) + end + + def get_drop_data_by_id(drops, id) do + Enum.find(drops, &(elem(&1, 0) == id)) |> elem(2) + end + + defp generate_options(values) do + Enum.map(values, &{&1.name, &1.id}) + end + + defp calculate_nothing_probability(drops) do + drops + |> Enum.map(&elem(&1, 3)) + |> Enum.reduce(1.0, fn form, acc -> + from_data = + if form.data.probability != nil do + Float.to_string(form.data.probability) + else + "0" + end + + acc - (Map.get(form.params, "probability", from_data) |> try_parse_float()) + end) + |> Float.round(12) + end + + defp try_parse_float(value) do + case Float.parse(value) do + {float, _} -> float + _ -> 0.0 + end + end + + defp forms_valid?(forms) do + Enum.all?(forms, fn form -> + form.source.valid? and + (form.params["prize_id"] || form.data.prize_id || form.params["badge_id"] || + form.data.badge_id || form.params["tokens"] || form.data.tokens || + form.params["entries"] || form.data.entries) + end) + end +end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex new file mode 100644 index 000000000..1ccb8d056 --- /dev/null +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex @@ -0,0 +1,225 @@ +defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do + @moduledoc false + alias Safira.Minigames.SlotsPaytable + use SafiraWeb, :live_component + + alias Safira.Contest + alias Safira.Minigames + alias Safira.Minigames.WheelDrop + + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={gettext("Slots Paytable")} subtitle={gettext("Configures the slots paytable.")}> + <:actions> + <.link patch={~p"/dashboard/minigames/wheel/simulator"}> + <.button> + <.icon name="hero-play" class="w-5 h-5" /> + + + +
+
+

<%= gettext("Entries") %>

+ <.button phx-click={JS.push("add-entry", target: @myself)}> + <.icon name="hero-plus" class="w-5 h-5" /> + +
+
    + <%= for {id, _drop, form} <- @entries do %> +
  • + <.simple_form + id={id} + for={form} + phx-change="validate" + phx-target={@myself} + class="!mt-0" + > + <.field type="hidden" name="identifier" value={id} /> +
    + <.field field={form[:multiplier]} type="number" wrapper_class="col-span-2" /> + <.field field={form[:probability]} type="number" wrapper_class="col-span-2" /> + <.link + phx-click={JS.push("delete-entry", value: %{id: id})} + data-confirm="Are you sure?" + phx-target={@myself} + class="content-center px-3" + > + <.icon name="hero-trash" class="w-5 h-5" /> + +
    + +
  • + <% end %> +
+ +
+ <.field + class="col-span-2" + type="number" + name="probability" + label="Remaining probability" + value={@nothing_probability} + readonly + disabled + /> +
+
+
+ <.button phx-click="save" phx-target={@myself} phx-disable-with="Saving..."> + Save Configuration + +
+ +
+ """ + end + + @impl true + def mount(socket) do + # Load the wheel drops + entries = + Minigames.list_slots_paytables() + |> Enum.map(fn entry -> + {Ecto.UUID.generate(), entry, to_form(Minigames.change_slots_paytable(entry))} + end) + + {:ok, + socket + |> assign(entries: entries) + |> assign(nothing_probability: calculate_nothing_probability(entries)) + |> assign(prizes: Minigames.list_prizes()) + |> assign(badges: Contest.list_badges())} + end + + @impl true + def handle_event("add-entry", _, socket) do + entries = socket.assigns.entries + + # Add a new drop to the list + {:noreply, + socket + |> assign( + :entries, + entries ++ + [ + {Ecto.UUID.generate(), %SlotsPaytable{}, + to_form(Minigames.change_slots_paytable(%SlotsPaytable{}))} + ] + )} + end + + @impl true + def handle_event("delete-entry", %{"id" => id}, socket) do + entries = socket.assigns.entries + # Find the drop to delete in the drops list + drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(2) + + # If the drop has an id, delete it from the database + if drop.id != nil do + Minigames.delete_wheel_drop(drop) + end + + # Remove the drop from the list + {:noreply, + socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end))} + end + + @impl true + def handle_event("validate", drop_params, socket) do + entries = socket.assigns.entries + entry = get_entry_data_by_id(entries, drop_params["identifier"]) + changeset = Minigames.change_slots_paytable(entry, drop_params["slots_paytable"]) + + # Update the form with the new changeset and the drop type if it changed + entries = + socket.assigns.entries + |> update_drop_form(drop_params["identifier"], to_form(changeset, action: :validate)) + + nothing_probability = calculate_nothing_probability(entries) + + {:noreply, + socket + |> assign(entries: entries) + |> assign(nothing_probability: nothing_probability)} + end + + @impl true + def handle_event("save", _params, socket) do + drops = socket.assigns.entries + + # Find if all the changesets are valid + valid_drops = + forms_valid?(Enum.map(drops, fn {_, _, form} -> form end)) and + calculate_nothing_probability(drops) == 0 + + if valid_drops do + # For each drop, update or create it + Enum.each(drops, fn {_, drop, form} -> + if drop.id != nil do + Minigames.update_slots_paytable(drop, form.params) + else + Minigames.create_slots_paytable(form.params) + end + end) + + {:noreply, + socket + |> put_flash(:info, "Slots paytable changed successfully") + |> push_patch(to: socket.assigns.patch)} + else + {:noreply, socket} + end + end + + defp update_drop_type_form_if_type_changed(drops, _id, _type), do: drops + + defp update_drop_form(entries, id, new_form) do + Enum.map(entries, fn + {^id, entry, _} -> {id, entry, new_form} + other -> other + end) + end + + def get_entry_data_by_id(drops, id) do + Enum.find(drops, &(elem(&1, 0) == id)) |> elem(1) + end + + defp generate_options(values) do + Enum.map(values, &{&1.name, &1.id}) + end + + defp calculate_nothing_probability(drops) do + drops + |> Enum.map(&elem(&1, 2)) + |> Enum.reduce(1.0, fn form, acc -> + from_data = + if form.data.probability != nil do + Float.to_string(form.data.probability) + else + "0" + end + + acc - (Map.get(form.params, "probability", from_data) |> try_parse_float()) + end) + |> Float.round(12) + end + + defp try_parse_float(value) do + case Float.parse(value) do + {float, _} -> float + _ -> 0.0 + end + end + + defp forms_valid?(forms) do + Enum.all?(forms, fn form -> + form.source.valid? and + not is_nil(form.params["multiplier"]) and + not is_nil(form.params["probability"]) + end) + end +end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex deleted file mode 100644 index 7c85141c5..000000000 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/playtable_live/form_component.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule SafiraWeb.Backoffice.MinigamesLive.Paytable.FormComponent do - @moduledoc false - use SafiraWeb, :live_component - - import SafiraWeb.Components.Forms - - alias Ecto.Changeset - alias Safira.Minigames - - def render(assigns) do - ~H""" -
- <.page - title={gettext("Slots Configuration")} - subtitle={gettext("Configures slots minigame's internal settings.")} - > -
- <.form - id="slots-config-form" - for={@form} - phx-submit="save" - phx-change="validate" - phx-target={@myself} - > -
- <.field - field={@form[:is_active]} - name="is_active" - label="Active" - type="switch" - help_text={gettext("Defines whether the slots minigame is active.")} - wrapper_class="my-6" - /> -
-
- <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %> -
- -
- <.link - patch={~p"/dashboard/minigames/slots"} - class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" - > - <%= gettext("Edit reels") %> - - - <.link - patch={~p"/dashboard/minigames/slots"} - class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors" - > - <%= gettext("Edit paytable") %> - -
-
- -
- """ - end - - def mount(socket) do - {:ok, - socket - |> assign( - form: - to_form( - %{ - "is_active" => Minigames.coin_flip_active?() - }, - as: :coin_flip_configuration - ) - )} - end - - def handle_event("validate", params, socket) do - changeset = validate_configuration(params["is_active"]) - - {:noreply, - assign(socket, form: to_form(changeset, action: :validate, as: :wheel_configuration))} - end - - def handle_event("save", params, socket) do - if valid_config?(params) do - Minigames.change_coin_flip_active("true" == params["is_active"]) - {:noreply, socket |> push_patch(to: ~p"/dashboard/minigames/")} - else - {:noreply, socket} - end - end - - defp validate_configuration(is_active) do - {%{}, %{is_active: :boolean}} - |> Changeset.cast(%{is_active: is_active}, [:is_active]) - end - - defp valid_config?(params) do - validation = validate_configuration(params["is_active"]) - validation.errors == [] - end -end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex index 68c9c55b8..c921d3a19 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex @@ -1,4 +1,4 @@ -defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent do +defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do @moduledoc false alias Safira.Minigames.SlotsReelIcon use SafiraWeb, :live_component @@ -11,7 +11,10 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent do def render(assigns) do ~H"""
- <.page title={gettext("Reels Configuration")} subtitle={gettext("Configures slots reels.")}> + <.page + title={gettext("Reel Icons Configuration")} + subtitle={gettext("Configures slots reel icons.")} + >
<.simple_form id="slots-reels-config-form" @@ -34,10 +37,10 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent do
<.field_label>Upload Images <.image_uploader - class="size-32 border-2 border-dashed" + class="size-36 border-2 border-dashed" upload={@uploads.images} preview_disabled - icon="hero-photo" + icon="hero-squares-plus" />
<%= for entry <- @uploads.images.entries do %> @@ -72,9 +75,18 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsIcons.FormComponent do <% end %> --%>
-

- <%= gettext("Number of icons: %{num_icons}", num_icons: length(@uploads.images.entries)) %> -

+
+

+ <%= gettext("Number of icons: %{num_icons}", + num_icons: length(@uploads.images.entries) + ) %> +

+

+ <.icon name="hero-exclamation-triangle" class="text-warning-600 mr-1" /><%= gettext( + "For optimal icon placement the number of icons should be a mutiple of 3." + ) %> +

+
<.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %>
diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex index 34615653d..4e20d688a 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex @@ -18,7 +18,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsPosition.FormComponent do
Minigames.get_slots_payline!(slots_payline.id) end + end + + test "change_slots_payline/1 returns a slots_payline changeset" do + slots_payline = slots_payline_fixture() + assert %Ecto.Changeset{} = Minigames.change_slots_payline(slots_payline) + end + end end diff --git a/test/support/fixtures/minigames_fixtures.ex b/test/support/fixtures/minigames_fixtures.ex index d8535562c..72069b7fa 100644 --- a/test/support/fixtures/minigames_fixtures.ex +++ b/test/support/fixtures/minigames_fixtures.ex @@ -81,4 +81,20 @@ defmodule Safira.MinigamesFixtures do slots_paytable end + + @doc """ + Generate a slots_payline. + """ + def slots_payline_fixture(attrs \\ %{}) do + {:ok, slots_payline} = + attrs + |> Enum.into(%{ + position_0: 42, + position_1: 42, + position_2: 42 + }) + |> Safira.Minigames.create_slots_payline() + + slots_payline + end end From 28883802c005bd4805c9661c23aec5561f82d90e Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Wed, 29 Jan 2025 18:31:44 +0000 Subject: [PATCH 03/12] fix: slots reels icon positions --- assets/js/hooks/ReelAnimation.js | 110 ++++++++----- lib/safira/minigames/slots_payline.ex | 4 +- .../live/app/slots_live/components/machine.ex | 10 +- lib/safira_web/live/app/slots_live/index.ex | 2 +- .../slots_live/payline_live/form_component.ex | 78 +++------- .../paytable_live/form_component.ex | 25 ++- .../reels_icons_live/form_component.ex | 4 +- .../20250127003102_create_slots_paylines.exs | 2 +- priv/static/images/slots.svg | 145 ++++++++++++++++++ 9 files changed, 270 insertions(+), 110 deletions(-) create mode 100644 priv/static/images/slots.svg diff --git a/assets/js/hooks/ReelAnimation.js b/assets/js/hooks/ReelAnimation.js index e1730fc8b..25582b77f 100644 --- a/assets/js/hooks/ReelAnimation.js +++ b/assets/js/hooks/ReelAnimation.js @@ -1,70 +1,98 @@ const rotationSpeed = 100 const extraTime = 1000 -const numIcons = 9 const iconSize = 79 export const ReelAnimation = { mounted() { + const numIcons = this.el.dataset.numIcons this.positions = Array(3).fill(0) this.absolutePositions = Array(3).fill(0) this.rotations = Array(3).fill(0) + this.isRolling = false this.handleEvent("roll_reels", ({ multiplier, target }) => { + if (this.isRolling) { + console.warn("Roll already in progress, skipping") + return + } + + this.isRolling = true const reelsList = document.querySelectorAll(".slots-container > .reel-slot") const promises = [] for (let i = 0; i < reelsList.length; i++) { + if (!reelsList[i]) { + console.error(`Reel ${i} not found`) + continue + } promises.push(this.roll(reelsList[i], i, target[i])) } - Promise.all(promises).then(() => { - this.pushEvent("roll_complete", { positions: this.positions }) - }) + Promise.all(promises) + .then(() => { + this.pushEvent("roll_complete", { positions: this.positions }) + }) + .catch(err => { + console.error("Roll failed:", err) + this.pushEvent("roll_error", { error: err.message }) + }) + .finally(() => { + this.isRolling = false + }) }) }, roll(reel, reelIndex, target) { - const minSpins = reelIndex + 2 - const currentPos = this.absolutePositions[reelIndex] - const currentIcon = Math.floor((currentPos / iconSize) % numIcons) - - // Calculate forward distance to target - let distance = target - currentIcon - if (distance <= 0) { - distance += numIcons - } - - const spinsInPixels = minSpins * numIcons * iconSize - const targetPixels = distance * iconSize - const delta = spinsInPixels + targetPixels - - return new Promise((resolve) => { - const newPosition = currentPos + delta - - setTimeout(() => { + return new Promise((resolve, reject) => { + try { const style = window.getComputedStyle(reel) const backgroundImage = style.backgroundImage + if (!backgroundImage) { + throw new Error(`No background image for reel ${reelIndex}`) + } + const numImages = backgroundImage.split(',').length - - const duration = (8 + delta/iconSize) * rotationSpeed - const transitions = Array(numImages).fill(`background-position-y ${duration}ms cubic-bezier(.41,-0.01,.63,1.09)`) + const minSpins = reelIndex + 2 + const currentPos = this.absolutePositions[reelIndex] + const currentIcon = Math.floor((currentPos / iconSize) % numImages) - // Use Array.from to ensure proper array creation and mapping - const positions = Array.from({length: numImages}, (_, index) => { - const initialOffset = index * iconSize - return `${newPosition + initialOffset}px` - }) - - reel.style.transition = transitions.join(', ') - reel.style.backgroundPositionY = positions.join(', ') - }, reelIndex * 150) + let distance = target - currentIcon + if (distance <= 0) { + distance += numImages + } - setTimeout(() => { - this.absolutePositions[reelIndex] = newPosition - this.positions[reelIndex] = Math.floor((newPosition / iconSize) % numIcons) - this.rotations[reelIndex]++ - resolve() - }, (8 + delta/iconSize) * rotationSpeed + reelIndex * 150 + extraTime) + const spinsInPixels = minSpins * numImages * iconSize + const targetPixels = distance * iconSize + const delta = spinsInPixels + targetPixels + const newPosition = currentPos + delta + + // Clear previous transition + reel.style.transition = 'none' + reel.offsetHeight // Force reflow + + setTimeout(() => { + const duration = (8 + delta/iconSize) * rotationSpeed + const transitions = Array(numImages).fill(`background-position-y ${duration}ms cubic-bezier(.41,-0.01,.63,1.09)`) + const positions = Array.from({length: numImages}, (_, index) => { + const initialOffset = index * iconSize + return `${newPosition + initialOffset}px` + }) + + reel.style.transition = transitions.join(', ') + reel.style.backgroundPositionY = positions.join(', ') + }, reelIndex * 150) + + setTimeout(() => { + // Clear transition after animation + reel.style.transition = 'none' + this.absolutePositions[reelIndex] = newPosition + this.positions[reelIndex] = Math.floor((newPosition / iconSize) % numImages) + this.rotations[reelIndex]++ + resolve() + }, (8 + delta/iconSize) * rotationSpeed + reelIndex * 150 + extraTime) + } catch (err) { + reject(err) + } }) -} + } } \ No newline at end of file diff --git a/lib/safira/minigames/slots_payline.ex b/lib/safira/minigames/slots_payline.ex index 62b999685..124a13552 100644 --- a/lib/safira/minigames/slots_payline.ex +++ b/lib/safira/minigames/slots_payline.ex @@ -6,10 +6,10 @@ defmodule Safira.Minigames.SlotsPayline do use Safira.Schema schema "slots_paylines" do - field :position_1, :integer field :position_0, :integer + field :position_1, :integer field :position_2, :integer - belongs_to :multiplier, Safira.Minigames.SlotsPaytable + belongs_to :paytable, Safira.Minigames.SlotsPaytable timestamps(type: :utc_datetime) end diff --git a/lib/safira_web/live/app/slots_live/components/machine.ex b/lib/safira_web/live/app/slots_live/components/machine.ex index d01efe3ed..41e719237 100644 --- a/lib/safira_web/live/app/slots_live/components/machine.ex +++ b/lib/safira_web/live/app/slots_live/components/machine.ex @@ -49,14 +49,19 @@ defmodule SafiraWeb.App.SlotsLive.Components.Machine do |> Map.update!(2, &[{reel, reel.reel_2_index} | &1]) end) |> Map.new(fn {k, v} -> - {k, v |> Enum.filter(fn {_, index} -> index != -1 end) |> Enum.sort_by(&elem(&1, 1))} + sorted = v + |> Enum.filter(fn {_, index} -> index != -1 end) + |> Enum.sort_by(&elem(&1, 1)) + + # Rotate first 3 items to end + {first_three, rest} = Enum.split(sorted, 3) + {k, rest ++ first_three} end) end defp build_reel_background(reel_images) do urls = reel_images - |> Enum.sort_by(&elem(&1, 1)) |> Enum.map_join(", ", fn {reel, _} -> url = SlotsReelIcon.url({reel.image, reel}, :original, signed: true) "url('#{url}')" @@ -67,7 +72,6 @@ defmodule SafiraWeb.App.SlotsLive.Components.Machine do defp build_background_positions(reel_images) do reel_images - |> Enum.sort_by(&elem(&1, 1)) |> Enum.with_index() |> Enum.map_join(", ", fn {_reel, index} -> position = index * 79 diff --git a/lib/safira_web/live/app/slots_live/index.ex b/lib/safira_web/live/app/slots_live/index.ex index 54cc3790a..cd261c454 100644 --- a/lib/safira_web/live/app/slots_live/index.ex +++ b/lib/safira_web/live/app/slots_live/index.ex @@ -68,7 +68,7 @@ defmodule SafiraWeb.App.SlotsLive.Index do # In your LiveView: def handle_event("spin", _params, socket) do - # target = [1, 2, 1] # Your target positions + # target = [0, 0, 0] # Your target positions target = Enum.map(1..3, fn _ -> Enum.random(1..9) end) {:noreply, push_event(socket, "roll_reels", %{multiplier: 2, target: target})} end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex index d1a4c5ac1..d0fe009de 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex @@ -5,7 +5,6 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do alias Safira.Contest alias Safira.Minigames - alias Safira.Minigames.WheelDrop import SafiraWeb.Components.Forms @@ -29,7 +28,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do
    - <%= for {id, type, _drop, form} <- @entries do %> + <%= for {id, _drop, form} <- @entries do %>
  • <.simple_form id={id} @@ -40,29 +39,10 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do > <.field type="hidden" name="identifier" value={id} />
    - <%!-- <%= if type == :nil do %> - <.field - wrapper_class="col-span-4" - value={nil} - placeholder="Select drop type" - options={[ - {"Select a drop type", nil}, - {"Prize", :prize}, - {"Badge", :badge}, - {"Tokens", :tokens}, - {"Entries", :entries} - ]} - name="set_type" - label="Type" - type="select" - /> - <% else %> - <%= if type == :prize do %> --%> <.field field={form[:position_0]} wrapper_class="col-span-1" type="number" /> <.field field={form[:position_1]} wrapper_class="col-span-1" type="number" /> <.field field={form[:position_2]} wrapper_class="col-span-1" type="number" /> - <.field field={form[:multiplier]} type="select" wrapper_class="col-span-2" /> - <%!-- <.field field={form[:probability]} type="number" wrapper_class="col-span-2" /> --%> + <.field field={form[:paytable_id]} type="select" wrapper_class="col-span-2" options={generate_options(@paytables)} /> <.link phx-click={JS.push("delete-entry", value: %{id: id})} data-confirm="Are you sure?" @@ -103,17 +83,17 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do def mount(socket) do # Load the wheel drops entries = - Minigames.list_wheel_drops() + Minigames.list_slots_paylines() |> Enum.map(fn entry -> - {Ecto.UUID.generate(), Minigames.get_wheel_drop_type(entry), entry, - to_form(Minigames.change_wheel_drop(entry))} + {Ecto.UUID.generate(), entry, + to_form(Minigames.change_slots_payline(entry))} end) {:ok, socket |> assign(entries: entries) - |> assign(nothing_probability: calculate_nothing_probability(entries)) - |> assign(prizes: Minigames.list_prizes()) + |> assign(nothing_probability: 0) + |> assign(paytables: Minigames.list_slots_paytables()) |> assign(badges: Contest.list_badges())} end @@ -128,8 +108,8 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do :entries, entries ++ [ - {Ecto.UUID.generate(), nil, %WheelDrop{}, - to_form(Minigames.change_wheel_drop(%WheelDrop{}))} + {Ecto.UUID.generate(), %SlotsPayline{}, + to_form(Minigames.change_slots_payline(%SlotsPayline{}))} ] )} end @@ -138,7 +118,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do def handle_event("delete-entry", %{"id" => id}, socket) do entries = socket.assigns.entries # Find the drop to delete in the drops list - drop = Enum.find(entries, fn {drop_id, _, _, _} -> drop_id == id end) |> elem(2) + drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(2) # If the drop has an id, delete it from the database if drop.id != nil do @@ -147,45 +127,40 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do # Remove the drop from the list {:noreply, - socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _, _} -> drop_id == id end))} + socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end))} end @impl true def handle_event("validate", drop_params, socket) do entries = socket.assigns.entries entry = get_drop_data_by_id(entries, drop_params["identifier"]) - changeset = Minigames.change_wheel_drop(entry, drop_params["wheel_drop"]) + changeset = Minigames.change_slots_payline(entry, drop_params["slots_payline"]) # Update the form with the new changeset and the drop type if it changed entries = socket.assigns.entries - |> update_drop_type_form_if_type_changed(drop_params["identifier"], drop_params["set_type"]) |> update_drop_form(drop_params["identifier"], to_form(changeset, action: :validate)) - nothing_probability = calculate_nothing_probability(entries) - {:noreply, socket - |> assign(entries: entries) - |> assign(nothing_probability: nothing_probability)} + |> assign(entries: entries)} end @impl true def handle_event("save", _params, socket) do - drops = socket.assigns.drops + entries = socket.assigns.entries # Find if all the changesets are valid valid_drops = - forms_valid?(Enum.map(drops, fn {_, _, _, form} -> form end)) and - calculate_nothing_probability(drops) >= 0 + forms_valid?(Enum.map(entries, fn {_, _, form} -> form end)) if valid_drops do # For each drop, update or create it - Enum.each(drops, fn {_, _, drop, form} -> + Enum.each(entries, fn {_, drop, form} -> if drop.id != nil do - Minigames.update_wheel_drop(drop, form.params) + Minigames.update_slots_payline(drop, form.params) else - Minigames.create_wheel_drop(form.params) + Minigames.create_slots_payline(form.params) end end) @@ -207,24 +182,24 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do defp update_drop_type_form_if_type_changed(drops, _id, _type), do: drops - defp update_drop_form(drops, id, new_form) do - Enum.map(drops, fn - {^id, drop_type, drop, _} -> {id, drop_type, drop, new_form} + defp update_drop_form(entries, id, new_form) do + Enum.map(entries, fn + {^id, entry, _} -> {id, entry, new_form} other -> other end) end def get_drop_data_by_id(drops, id) do - Enum.find(drops, &(elem(&1, 0) == id)) |> elem(2) + Enum.find(drops, &(elem(&1, 0) == id)) |> elem(1) end defp generate_options(values) do - Enum.map(values, &{&1.name, &1.id}) + Enum.map(values, &{&1.multiplier, &1.id}) end defp calculate_nothing_probability(drops) do drops - |> Enum.map(&elem(&1, 3)) + |> Enum.map(&elem(&1, 2)) |> Enum.reduce(1.0, fn form, acc -> from_data = if form.data.probability != nil do @@ -247,10 +222,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do defp forms_valid?(forms) do Enum.all?(forms, fn form -> - form.source.valid? and - (form.params["prize_id"] || form.data.prize_id || form.params["badge_id"] || - form.data.badge_id || form.params["tokens"] || form.data.tokens || - form.params["entries"] || form.data.entries) + form.source.valid? end) end end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex index 1ccb8d056..532d2dbf1 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex @@ -116,16 +116,19 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do def handle_event("delete-entry", %{"id" => id}, socket) do entries = socket.assigns.entries # Find the drop to delete in the drops list - drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(2) + drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(1) # If the drop has an id, delete it from the database if drop.id != nil do - Minigames.delete_wheel_drop(drop) + Minigames.delete_slots_paytable(drop) end + + nothing_probability = calculate_nothing_probability(entries) # Remove the drop from the list {:noreply, - socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end))} + socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end)) + |> assign(nothing_probability: nothing_probability)} end @impl true @@ -154,8 +157,11 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do # Find if all the changesets are valid valid_drops = forms_valid?(Enum.map(drops, fn {_, _, form} -> form end)) and - calculate_nothing_probability(drops) == 0 + calculate_nothing_probability(drops) >= 0 + IO.inspect(drops) + IO.inspect(valid_drops) + IO.inspect(forms_valid?(Enum.map(drops, fn {_, _, form} -> form end))) if valid_drops do # For each drop, update or create it Enum.each(drops, fn {_, drop, form} -> @@ -217,9 +223,14 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do defp forms_valid?(forms) do Enum.all?(forms, fn form -> - form.source.valid? and - not is_nil(form.params["multiplier"]) and - not is_nil(form.params["probability"]) + form.source.valid? and has_valid_values?(form) end) end + + defp has_valid_values?(form) do + params_valid = not is_nil(form.params["multiplier"]) and not is_nil(form.params["probability"]) + data_valid = not is_nil(form.data.multiplier) and not is_nil(form.data.probability) + + params_valid or data_valid + end end diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex index c921d3a19..b2eae67e9 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex @@ -83,7 +83,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do

    <.icon name="hero-exclamation-triangle" class="text-warning-600 mr-1" /><%= gettext( - "For optimal icon placement the number of icons should be a mutiple of 3." + "For optimal icon placement the number of icons should be 9 and each icon should be a square image." ) %>

    @@ -104,7 +104,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do |> assign(:reel, %SlotsReelIcon{}) |> allow_upload(:images, accept: Uploaders.SlotsReelIcon.extension_whitelist(), - max_entries: 10 + max_entries: 15 ) |> assign(form: to_form(%{}))} end diff --git a/priv/repo/migrations/20250127003102_create_slots_paylines.exs b/priv/repo/migrations/20250127003102_create_slots_paylines.exs index f5a6142ea..71c2c95e0 100644 --- a/priv/repo/migrations/20250127003102_create_slots_paylines.exs +++ b/priv/repo/migrations/20250127003102_create_slots_paylines.exs @@ -7,7 +7,7 @@ defmodule Safira.Repo.Migrations.CreateSlotsPaylines do add :position_0, :integer add :position_1, :integer add :position_2, :integer - add :multiplier_id, references(:slots_paytables, type: :binary_id, on_delete: :delete_all) + add :paytable_id, references(:slots_paytables, type: :binary_id, on_delete: :delete_all) timestamps(type: :utc_datetime) end diff --git a/priv/static/images/slots.svg b/priv/static/images/slots.svg new file mode 100644 index 000000000..fd9ccd99e --- /dev/null +++ b/priv/static/images/slots.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + From eb8662ec19dcbd9fa2b61e0b5a3d85a071ff25f8 Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Sat, 1 Feb 2025 17:58:12 +0000 Subject: [PATCH 04/12] feat: implement slots spin transaction and payline --- lib/safira/minigames.ex | 172 ++++++++++++++++++ lib/safira/minigames/slots_payline.ex | 9 +- .../live/app/slots_live/components/machine.ex | 9 +- lib/safira_web/live/app/slots_live/index.ex | 47 ++++- .../live/app/slots_live/index.html.heex | 52 +++++- .../slots_live/payline_live/form_component.ex | 69 +++++-- .../paytable_live/form_component.ex | 20 +- 7 files changed, 336 insertions(+), 42 deletions(-) diff --git a/lib/safira/minigames.ex b/lib/safira/minigames.ex index e3887b9f5..5af28ec73 100644 --- a/lib/safira/minigames.ex +++ b/lib/safira/minigames.ex @@ -1322,4 +1322,176 @@ defmodule Safira.Minigames do def change_slots_payline(%SlotsPayline{} = slots_payline, attrs \\ %{}) do SlotsPayline.changeset(slots_payline, attrs) end + + @doc """ + Spins the wheel for the given attendee. + + ## Examples + + iex> spin_wheel(attendee) + {:ok, :prize, %WheelDrop{}} + + iex> spin_wheel(attendee) + {:ok, :tokens, %WheelDrop{}} + + iex> spin_wheel(attendee) + {:ok, nil, %WheelDrop{}} + """ + def spin_slots(attendee, bet) do + attendee = Accounts.get_attendee!(attendee.id) + + if slots_active?() do + case spin_slots_transaction(attendee, bet) do + {:ok, result} -> + IO.inspect(result.paytable_entry, label: "Paytable Entry") + {:ok, result.target, result.attendee_state_tokens.tokens} + + {:error, _} -> + {:error, "An error occurred while spinning the slots."} + end + else + {:error, "The slots are not active."} + end + end + + defp spin_slots_transaction(attendee, bet) do + Multi.new() + # Remove the bet from attendee's balance + |> Multi.merge(fn _changes -> + Contest.change_attendee_tokens_transaction(attendee, attendee.tokens - bet, :attendee) + end) + |> Multi.put(:paylines, list_slots_paylines()) + |> Multi.put(:slots_reel_icons_count, count_visible_slots_reel_icons(list_slots_reel_icons())) + # Get random multiplier from paytable based on probabilities + |> Multi.run(:paytable_entry, fn _repo, %{paylines: paylines} -> + {:ok, generate_slots_multiplier(paylines)} + end) + # Get random payline for the selected multiplier + |> Multi.run(:target, fn _repo, + %{ + paylines: paylines, + slots_reel_icons_count: slots_reel_icons_count, + paytable_entry: multiplier + } -> + {:ok, generate_slots_target(paylines, slots_reel_icons_count, multiplier)} + end) + # Award tokens based on multiplier + |> Multi.merge(fn %{paytable_entry: multiplier, attendee: attendee} -> + winnings = bet * multiplier.multiplier + + Contest.change_attendee_tokens_transaction( + attendee, + attendee.tokens + winnings, + :attendee_state_tokens, + :previous_daily_tokens, + :new_daily_tokens + ) + end) + |> Repo.transaction() + end + + defp generate_slots_multiplier(paylines) do + random = strong_randomizer() |> Float.round(12) + multipliers = list_slots_paytables() + + cumulative_probabilities = + multipliers + |> Enum.sort_by(& &1.probability) + |> Enum.map_reduce(0, fn multiplier, acc -> + {Float.round(acc + multiplier.probability, 12), acc + multiplier.probability} + end) + + total_prob = elem(cumulative_probabilities, 1) + + if random > total_prob do + # Return losing multiplier for remaining probability + %SlotsPaytable{multiplier: 0, probability: 1 - total_prob} + else + prob = + cumulative_probabilities + |> elem(0) + |> Enum.filter(fn x -> x >= random end) + |> Enum.at(0) + + paytable_entry = + Enum.sort_by(multipliers, & &1.probability) + |> Enum.at(cumulative_probabilities |> elem(0) |> Enum.find_index(fn x -> x == prob end)) + + filtered_paylines = paylines |> Enum.filter(&(&1.paytable_id == paytable_entry.id)) + + if Enum.empty?(filtered_paylines) do + # Generate random multiplier if no payline exists + %SlotsPaytable{multiplier: 0, probability: 1 - total_prob} + else + paytable_entry + end + end + end + + defp generate_slots_target(paylines, slots_reel_icons_count, multiplier) do + if multiplier.multiplier == 0 do + # For losing case, generate target that doesn't match any payline + all_paylines = list_slots_paylines() + generate_non_matching_target(all_paylines, slots_reel_icons_count) + else + paylines = paylines |> Enum.filter(&(&1.paytable_id == multiplier.id)) + payline = Enum.random(paylines) + # if the position is nil than it should be random + position_0 = + if payline.position_0 == nil, + do: Enum.random(0..(slots_reel_icons_count[0] - 1)), + else: payline.position_0 + + position_1 = + if payline.position_1 == nil, + do: Enum.random(0..(slots_reel_icons_count[1] - 1)), + else: payline.position_1 + + position_2 = + if payline.position_2 == nil, + do: Enum.random(0..(slots_reel_icons_count[2] - 1)), + else: payline.position_2 + + [position_0, position_1, position_2] + end + end + + defp generate_non_matching_target(paylines, slots_reel_icons_count) do + target = [ + Enum.random(0..(slots_reel_icons_count[0] - 1)), + Enum.random(0..(slots_reel_icons_count[1] - 1)), + Enum.random(0..(slots_reel_icons_count[2] - 1)) + ] + + if Enum.any?(paylines, fn p -> + [p.position_0, p.position_1, p.position_2] == target + end) do + generate_non_matching_target(paylines, slots_reel_icons_count) + else + target + end + end + + @doc """ + Counts the number of visible slots reel icons in each reel. + + ## Examples + + iex> count_visible_slots_reel_icons(slots_icons) + %{0 => 3, 1 => 3, 2 => 3} + """ + def count_visible_slots_reel_icons(slots_icons) do + slots_icons + |> Enum.reduce(%{}, fn icon, acc -> + visible_in_reel_0 = icon.reel_0_index != -1 + visible_in_reel_1 = icon.reel_1_index != -1 + visible_in_reel_2 = icon.reel_2_index != -1 + + Map.merge(acc, %{ + 0 => if(visible_in_reel_0, do: Map.get(acc, 0, 0) + 1, else: Map.get(acc, 0, 0)), + 1 => if(visible_in_reel_1, do: Map.get(acc, 1, 0) + 1, else: Map.get(acc, 1, 0)), + 2 => if(visible_in_reel_2, do: Map.get(acc, 2, 0) + 1, else: Map.get(acc, 2, 0)) + }) + end) + end end diff --git a/lib/safira/minigames/slots_payline.ex b/lib/safira/minigames/slots_payline.ex index 124a13552..6b337441e 100644 --- a/lib/safira/minigames/slots_payline.ex +++ b/lib/safira/minigames/slots_payline.ex @@ -5,6 +5,9 @@ defmodule Safira.Minigames.SlotsPayline do """ use Safira.Schema + @required_fields ~w(paytable_id)a + @optional_fields ~w(position_0 position_1 position_2)a + schema "slots_paylines" do field :position_0, :integer field :position_1, :integer @@ -17,8 +20,8 @@ defmodule Safira.Minigames.SlotsPayline do @doc false def changeset(slots_payline, attrs) do slots_payline - |> cast(attrs, [:position_0, :position_1, :position_2]) - |> foreign_key_constraint(:multiplier_id) - |> validate_required([:position_0, :position_1, :position_2]) + |> cast(attrs, @required_fields ++ @optional_fields) + |> foreign_key_constraint(:paytable_id) + |> validate_required(@required_fields) end end diff --git a/lib/safira_web/live/app/slots_live/components/machine.ex b/lib/safira_web/live/app/slots_live/components/machine.ex index 41e719237..74b41b441 100644 --- a/lib/safira_web/live/app/slots_live/components/machine.ex +++ b/lib/safira_web/live/app/slots_live/components/machine.ex @@ -49,10 +49,11 @@ defmodule SafiraWeb.App.SlotsLive.Components.Machine do |> Map.update!(2, &[{reel, reel.reel_2_index} | &1]) end) |> Map.new(fn {k, v} -> - sorted = v - |> Enum.filter(fn {_, index} -> index != -1 end) - |> Enum.sort_by(&elem(&1, 1)) - + sorted = + v + |> Enum.filter(fn {_, index} -> index != -1 end) + |> Enum.sort_by(&elem(&1, 1)) + # Rotate first 3 items to end {first_three, rest} = Enum.split(sorted, 3) {k, rest ++ first_three} diff --git a/lib/safira_web/live/app/slots_live/index.ex b/lib/safira_web/live/app/slots_live/index.ex index cd261c454..5adf094cf 100644 --- a/lib/safira_web/live/app/slots_live/index.ex +++ b/lib/safira_web/live/app/slots_live/index.ex @@ -19,6 +19,7 @@ defmodule SafiraWeb.App.SlotsLive.Index do |> assign(:attendee_tokens, socket.assigns.current_user.attendee.tokens) |> assign(:wheel_price, Minigames.get_wheel_price()) |> assign(:result, nil) + |> assign(:bet, 10) |> assign(:slots_active?, Minigames.slots_active?())} end @@ -66,16 +67,52 @@ defmodule SafiraWeb.App.SlotsLive.Index do |> assign(:result, nil)} end - # In your LiveView: def handle_event("spin", _params, socket) do - # target = [0, 0, 0] # Your target positions - target = Enum.map(1..3, fn _ -> Enum.random(1..9) end) - {:noreply, push_event(socket, "roll_reels", %{multiplier: 2, target: target})} + if socket.assigns.bet <= 0 do + {:noreply, + socket + |> put_flash(:error, gettext("Please set a bet amount greater than 0."))} + else + case Minigames.spin_slots(socket.assigns.current_user.attendee, socket.assigns.bet) do + {:ok, target, attendee_tokens} -> + IO.inspect("Spin successful") + IO.inspect(socket.assigns.bet) + IO.inspect(target) + IO.inspect(attendee_tokens) + IO.inspect(socket.assigns.attendee_tokens) + + {:noreply, + socket + |> assign(:in_spin?, true) + |> assign(:new_attendee_tokens, attendee_tokens) + |> assign(:attendee_tokens, socket.assigns.attendee_tokens - socket.assigns.bet) + |> push_event("roll_reels", %{target: target})} + + {:error, message} -> + {:noreply, + socket + |> assign(:in_spin?, false) + |> assign(:attendee_tokens, socket.assigns.attendee_tokens + socket.assigns.bet) + |> put_flash(:error, message)} + end + end end @impl true def handle_event("roll_complete", _params, socket) do - {:noreply, socket} + {:noreply, + socket + |> assign(:in_spin?, false) + |> assign(:attendee_tokens, socket.assigns.new_attendee_tokens)} + end + + @impl true + def handle_event("set-bet", %{"bet" => bet}, socket) do + bet = if bet != "", do: String.to_integer(bet), else: 0 + + {:noreply, + socket + |> assign(:bet, bet)} end @impl true diff --git a/lib/safira_web/live/app/slots_live/index.html.heex b/lib/safira_web/live/app/slots_live/index.html.heex index 48f730fd1..036230c0e 100644 --- a/lib/safira_web/live/app/slots_live/index.html.heex +++ b/lib/safira_web/live/app/slots_live/index.html.heex @@ -5,15 +5,51 @@ <.machine /> -
    - <.action_button - title={gettext("Spin")} - subtitle={"💰 #{@wheel_price}"} - class="w-64" - disabled={!can_spin?(@slots_active?, @attendee_tokens, @wheel_price, @in_spin?)} - phx-click="spin" - /> +
    +
    + <.icon name="hero-currency-dollar-solid" class="text-yellow-300 ml-2" /> + <.form for={%{}} phx-change="set-bet" class="w-full"> + + +
    +
    + + + +
    + <.action_button + title={gettext("Spin")} + class="max-w-80 mx-0" + disabled={@bet > @attendee_tokens || not @slots_active? || @in_spin?} + phx-click="spin" + /> <.result_modal diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex index d0fe009de..2680b66eb 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/payline_live/form_component.ex @@ -39,10 +39,30 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do > <.field type="hidden" name="identifier" value={id} />
    - <.field field={form[:position_0]} wrapper_class="col-span-1" type="number" /> - <.field field={form[:position_1]} wrapper_class="col-span-1" type="number" /> - <.field field={form[:position_2]} wrapper_class="col-span-1" type="number" /> - <.field field={form[:paytable_id]} type="select" wrapper_class="col-span-2" options={generate_options(@paytables)} /> + <.field + field={form[:position_0]} + wrapper_class="col-span-1" + type="select" + options={generate_position_options(@slots_reel_icons, 0)} + /> + <.field + field={form[:position_1]} + wrapper_class="col-span-1" + type="select" + options={generate_position_options(@slots_reel_icons, 1)} + /> + <.field + field={form[:position_2]} + wrapper_class="col-span-1" + type="select" + options={generate_position_options(@slots_reel_icons, 2)} + /> + <.field + field={form[:paytable_id]} + type="select" + wrapper_class="col-span-2" + options={generate_options_multiplier(@paytables)} + /> <.link phx-click={JS.push("delete-entry", value: %{id: id})} data-confirm="Are you sure?" @@ -85,31 +105,35 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do entries = Minigames.list_slots_paylines() |> Enum.map(fn entry -> - {Ecto.UUID.generate(), entry, - to_form(Minigames.change_slots_payline(entry))} + {Ecto.UUID.generate(), entry, to_form(Minigames.change_slots_payline(entry))} end) + slots_reel_icons = Minigames.list_slots_reel_icons() + {:ok, socket |> assign(entries: entries) |> assign(nothing_probability: 0) |> assign(paytables: Minigames.list_slots_paytables()) - |> assign(badges: Contest.list_badges())} + |> assign(slots_reel_icons: slots_reel_icons)} end @impl true def handle_event("add-entry", _, socket) do entries = socket.assigns.entries + default_paytable = List.first(socket.assigns.paytables) + default_paytable_id = if default_paytable != nil, do: default_paytable.id, else: nil - # Add a new drop to the list {:noreply, socket |> assign( :entries, entries ++ [ - {Ecto.UUID.generate(), %SlotsPayline{}, - to_form(Minigames.change_slots_payline(%SlotsPayline{}))} + {Ecto.UUID.generate(), %SlotsPayline{paytable_id: default_paytable_id}, + to_form( + Minigames.change_slots_payline(%SlotsPayline{paytable_id: default_paytable_id}) + )} ] )} end @@ -118,11 +142,11 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do def handle_event("delete-entry", %{"id" => id}, socket) do entries = socket.assigns.entries # Find the drop to delete in the drops list - drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(2) + drop = Enum.find(entries, fn {drop_id, _, _} -> drop_id == id end) |> elem(1) # If the drop has an id, delete it from the database if drop.id != nil do - Minigames.delete_wheel_drop(drop) + Minigames.delete_slots_payline(drop) end # Remove the drop from the list @@ -154,13 +178,17 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do valid_drops = forms_valid?(Enum.map(entries, fn {_, _, form} -> form end)) + IO.inspect(valid_drops, label: "Valid Drops") + if valid_drops do # For each drop, update or create it Enum.each(entries, fn {_, drop, form} -> if drop.id != nil do Minigames.update_slots_payline(drop, form.params) else - Minigames.create_slots_payline(form.params) + IO.inspect(form.params, label: "Form Params") + res = Minigames.create_slots_payline(form.params) + IO.inspect(res, label: "Create Result") end end) @@ -193,10 +221,23 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPayline.FormComponent do Enum.find(drops, &(elem(&1, 0) == id)) |> elem(1) end - defp generate_options(values) do + defp generate_options_multiplier(values) do Enum.map(values, &{&1.multiplier, &1.id}) end + defp generate_position_options(slots_icons, reel_index) do + visible_count = Minigames.count_visible_slots_reel_icons(slots_icons)[reel_index] + + visible_count_array = + if visible_count == nil do + [] + else + Enum.map(0..(visible_count - 1), fn pos -> {Integer.to_string(pos), pos} end) + end + + [{"Any", nil}] ++ visible_count_array + end + defp calculate_nothing_probability(drops) do drops |> Enum.map(&elem(&1, 2)) diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex index 532d2dbf1..d880a4f61 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/paytable_live/form_component.ex @@ -122,12 +122,13 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do if drop.id != nil do Minigames.delete_slots_paytable(drop) end - + nothing_probability = calculate_nothing_probability(entries) # Remove the drop from the list {:noreply, - socket |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end)) + socket + |> assign(entries: Enum.reject(entries, fn {drop_id, _, _} -> drop_id == id end)) |> assign(nothing_probability: nothing_probability)} end @@ -159,9 +160,10 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do forms_valid?(Enum.map(drops, fn {_, _, form} -> form end)) and calculate_nothing_probability(drops) >= 0 - IO.inspect(drops) - IO.inspect(valid_drops) - IO.inspect(forms_valid?(Enum.map(drops, fn {_, _, form} -> form end))) + IO.inspect(drops) + IO.inspect(valid_drops) + IO.inspect(forms_valid?(Enum.map(drops, fn {_, _, form} -> form end))) + if valid_drops do # For each drop, update or create it Enum.each(drops, fn {_, drop, form} -> @@ -226,11 +228,13 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.SlotsPaytable.FormComponent do form.source.valid? and has_valid_values?(form) end) end - + defp has_valid_values?(form) do - params_valid = not is_nil(form.params["multiplier"]) and not is_nil(form.params["probability"]) + params_valid = + not is_nil(form.params["multiplier"]) and not is_nil(form.params["probability"]) + data_valid = not is_nil(form.data.multiplier) and not is_nil(form.data.probability) - + params_valid or data_valid end end From e48671df0f05dd185e04c0f5f1f4d4fd95765960 Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Sun, 2 Feb 2025 12:47:08 +0000 Subject: [PATCH 05/12] feat: slots result modal --- assets/js/hooks/ReelAnimation.js | 7 +- lib/safira/minigames.ex | 12 ++- .../live/app/slots_live/components/machine.ex | 4 +- .../app/slots_live/components/result_modal.ex | 83 +++++----------- lib/safira_web/live/app/slots_live/index.ex | 13 ++- .../live/app/slots_live/index.html.heex | 94 ++++++++++--------- .../reels_icons_live/form_component.ex | 40 ++------ .../reels_position_live/form_component.ex | 44 +++++++-- 8 files changed, 139 insertions(+), 158 deletions(-) diff --git a/assets/js/hooks/ReelAnimation.js b/assets/js/hooks/ReelAnimation.js index 25582b77f..e137838b0 100644 --- a/assets/js/hooks/ReelAnimation.js +++ b/assets/js/hooks/ReelAnimation.js @@ -56,7 +56,8 @@ export const ReelAnimation = { const currentPos = this.absolutePositions[reelIndex] const currentIcon = Math.floor((currentPos / iconSize) % numImages) - let distance = target - currentIcon + const reversedTarget = numImages - target + let distance = reversedTarget - currentIcon if (distance <= 0) { distance += numImages } @@ -82,11 +83,13 @@ export const ReelAnimation = { reel.style.backgroundPositionY = positions.join(', ') }, reelIndex * 150) + setTimeout(() => { // Clear transition after animation reel.style.transition = 'none' this.absolutePositions[reelIndex] = newPosition - this.positions[reelIndex] = Math.floor((newPosition / iconSize) % numImages) + // Also adjust the final position calculation + this.positions[reelIndex] = numImages - Math.floor((newPosition / iconSize) % numImages) this.rotations[reelIndex]++ resolve() }, (8 + delta/iconSize) * rotationSpeed + reelIndex * 150 + extraTime) diff --git a/lib/safira/minigames.ex b/lib/safira/minigames.ex index 5af28ec73..518d843f1 100644 --- a/lib/safira/minigames.ex +++ b/lib/safira/minigames.ex @@ -1344,7 +1344,9 @@ defmodule Safira.Minigames do case spin_slots_transaction(attendee, bet) do {:ok, result} -> IO.inspect(result.paytable_entry, label: "Paytable Entry") - {:ok, result.target, result.attendee_state_tokens.tokens} + + {:ok, result.target, result.paytable_entry.multiplier, + result.attendee_state_tokens.tokens, result.winnings} {:error, _} -> {:error, "An error occurred while spinning the slots."} @@ -1375,10 +1377,12 @@ defmodule Safira.Minigames do } -> {:ok, generate_slots_target(paylines, slots_reel_icons_count, multiplier)} end) + |> Multi.run(:winnings, fn _repo, %{paytable_entry: paytable_entry} -> + winnings = bet * paytable_entry.multiplier + {:ok, winnings} + end) # Award tokens based on multiplier - |> Multi.merge(fn %{paytable_entry: multiplier, attendee: attendee} -> - winnings = bet * multiplier.multiplier - + |> Multi.merge(fn %{attendee: attendee, winnings: winnings} -> Contest.change_attendee_tokens_transaction( attendee, attendee.tokens + winnings, diff --git a/lib/safira_web/live/app/slots_live/components/machine.ex b/lib/safira_web/live/app/slots_live/components/machine.ex index 74b41b441..e9f1df869 100644 --- a/lib/safira_web/live/app/slots_live/components/machine.ex +++ b/lib/safira_web/live/app/slots_live/components/machine.ex @@ -18,8 +18,8 @@ defmodule SafiraWeb.App.SlotsLive.Components.Machine do ~H"""
    -
    -
    +
    +
    <%= for reel_num <- 0..2 do %>
    1} class="relative z-50 hidden" >
    @@ -85,34 +60,20 @@ defmodule SafiraWeb.App.SlotsLive.Components.ResultModal do """ end - defp get_drop_result_text(drop_type, drop) do - case drop_type do - :prize -> - gettext("Congratulations! You won %{prize_name} ✨", prize_name: drop.prize.name) - - :badge -> - gettext("Congratulations! You won the %{badge_name} badge!", badge_name: drop.badge.name) - - :tokens -> - if drop.tokens == 1 do - gettext("Congratulations! You won %{tokens} token 💰!", tokens: drop.tokens) - else - gettext("Congratulations! You won %{tokens} tokens 💰!", tokens: drop.tokens) - end + defp get_spin_result_title(multiplier) do + cond do + multiplier == 1 -> gettext("Bet refunded! 💰") + multiplier > 1 -> gettext("You won tokens! 🎉") + end + end - :entries -> - if drop.entries == 1 do - gettext("Congratulations! You won 🎫 %{entries} entry to the final draw!", - entries: drop.entries - ) - else - gettext("Congratulations! You won 🎫 %{entries} entries to the final draw!", - entries: drop.entries - ) - end + defp get_spin_result_text(multiplier, winnings) do + cond do + multiplier == 1 -> + gettext("Phew, your bet was refunded! Will you try your luck with another spin?") - _ -> - gettext("Oops.. You didn't win anything.. Maybe spin again? 👀") + multiplier > 1 -> + gettext("Congratulations! You won %{winnings} tokens!", winnings: winnings) end end end diff --git a/lib/safira_web/live/app/slots_live/index.ex b/lib/safira_web/live/app/slots_live/index.ex index 5adf094cf..2c3dd8bec 100644 --- a/lib/safira_web/live/app/slots_live/index.ex +++ b/lib/safira_web/live/app/slots_live/index.ex @@ -67,14 +67,14 @@ defmodule SafiraWeb.App.SlotsLive.Index do |> assign(:result, nil)} end - def handle_event("spin", _params, socket) do + def handle_event("spin-slots", _params, socket) do if socket.assigns.bet <= 0 do {:noreply, socket |> put_flash(:error, gettext("Please set a bet amount greater than 0."))} else case Minigames.spin_slots(socket.assigns.current_user.attendee, socket.assigns.bet) do - {:ok, target, attendee_tokens} -> + {:ok, target, multiplier, attendee_tokens, winnings} -> IO.inspect("Spin successful") IO.inspect(socket.assigns.bet) IO.inspect(target) @@ -84,7 +84,12 @@ defmodule SafiraWeb.App.SlotsLive.Index do {:noreply, socket |> assign(:in_spin?, true) - |> assign(:new_attendee_tokens, attendee_tokens) + |> assign(:result, %{ + multiplier: multiplier, + target: target, + new_attendee_tokens: attendee_tokens, + winnings: winnings + }) |> assign(:attendee_tokens, socket.assigns.attendee_tokens - socket.assigns.bet) |> push_event("roll_reels", %{target: target})} @@ -103,7 +108,7 @@ defmodule SafiraWeb.App.SlotsLive.Index do {:noreply, socket |> assign(:in_spin?, false) - |> assign(:attendee_tokens, socket.assigns.new_attendee_tokens)} + |> assign(:attendee_tokens, socket.assigns.result.new_attendee_tokens)} end @impl true diff --git a/lib/safira_web/live/app/slots_live/index.html.heex b/lib/safira_web/live/app/slots_live/index.html.heex index 036230c0e..ab0d5e552 100644 --- a/lib/safira_web/live/app/slots_live/index.html.heex +++ b/lib/safira_web/live/app/slots_live/index.html.heex @@ -5,57 +5,59 @@ <.machine /> -
    -
    - <.icon name="hero-currency-dollar-solid" class="text-yellow-300 ml-2" /> - <.form for={%{}} phx-change="set-bet" class="w-full"> - - -
    -
    - - - +
    +
    +
    + <.icon name="hero-currency-dollar-solid" class="text-yellow-300 ml-2" /> + <.form for={%{}} phx-change="set-bet" class="w-full"> + + +
    +
    + + + +
    + <.action_button + title={gettext("Spin")} + class="max-w-80" + disabled={@bet > @attendee_tokens || not @slots_active? || @in_spin?} + phx-click="spin-slots" + />
    - <.action_button - title={gettext("Spin")} - class="max-w-80 mx-0" - disabled={@bet > @attendee_tokens || not @slots_active? || @in_spin?} - phx-click="spin" - /> <.result_modal - :if={@result} - drop_type={@result.type} - drop={@result.drop} + :if={@result && not @in_spin? && @result.winnings > 0} + multiplier={@result.multiplier} + winnings={@result.winnings} wrapper_class="px-6" show id="confirm" diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex index b2eae67e9..6d06bfd59 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_icons_live/form_component.ex @@ -23,18 +23,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do phx-change="validate" phx-target={@myself} > - <%!--
    - <.field_label>Number of icons - <.input - field={@form[:number_of_icons]} - name="number_of_icons" - type="number" - min="1" - value={@number_of_icons} - phx-debounce="blur" - /> -
    --%> -
    +
    <.field_label>Upload Images <.image_uploader class="size-36 border-2 border-dashed" @@ -46,7 +35,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do <%= for entry <- @uploads.images.entries do %>
    <.live_img_preview entry={entry} class="size-32 object-cover rounded-lg" /> -
    +
    <%= for err <- upload_errors(@uploads.images, entry) do %> @@ -67,26 +56,13 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelIcons.FormComponent do No images uploaded
    <% end %> - <%!-- - <%= for i <- length(@uploads.images.entries)..(@number_of_icons - 1) do %> -
    - Empty slot <%= i + 1 %> -
    - <% end %> --%>
    -
    -

    - <%= gettext("Number of icons: %{num_icons}", - num_icons: length(@uploads.images.entries) - ) %> -

    -

    - <.icon name="hero-exclamation-triangle" class="text-warning-600 mr-1" /><%= gettext( - "For optimal icon placement the number of icons should be 9 and each icon should be a square image." - ) %> -

    -
    +

    + <.icon name="hero-exclamation-triangle" class="text-warning-600 mr-1" /><%= gettext( + "Each icon should be a square image." + ) %> +

    <.button phx-disable-with="Saving..."><%= gettext("Save Configuration") %>
    diff --git a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex index 4e20d688a..d806f4a05 100644 --- a/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex +++ b/lib/safira_web/live/backoffice/minigames_live/slots_live/reels_position_live/form_component.ex @@ -14,7 +14,7 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsPosition.FormComponent do subtitle={gettext("Configures slots reels.")} >
    -
    +
    -

    Drag and drop the icons to change their order

    -

    Number of icons: <%= length(@slots_icons_per_column[0]) %>

    -

    Number of icons: <%= length(@slots_icons_per_column[1]) %>

    -

    Number of icons: <%= length(@slots_icons_per_column[2]) %>

    + <%= for i <- 0..2 do %> +

    + Reel <%= i %>: <%= count_visible_icons( + @visibility[i] + ) %> visible icons +

    + <% end %> +

    + <.icon name="hero-exclamation-triangle" class="text-warning-500 mr-1" /><%= gettext( + "For optimal icon placement the number of icons should be 9." + ) %> +

    +

    + <.icon name="hero-information-circle" class="text-blue-500 mr-1" /><%= gettext( + "Drag and drop the icons to change their order." + ) %> +

    - <.button phx-click="save" phx-target={@myself} phx-disable-with="Saving..."> + <.button + phx-click="save" + phx-target={@myself} + phx-disable-with="Saving..." + disabled={not all_reels_match?(@visibility)} + > <%= gettext("Save Configuration") %>
    @@ -262,4 +283,13 @@ defmodule SafiraWeb.Backoffice.MinigamesLive.ReelsPosition.FormComponent do |> Enum.filter(fn icon -> Map.has_key?(reel_order, icon.id) end) |> Enum.sort_by(fn icon -> reel_order[icon.id] end) end + + defp count_visible_icons(visibility) do + Enum.count(visibility, fn {_id, visible} -> visible end) + end + + defp all_reels_match?(visibility) do + counts = Enum.map(0..2, fn i -> count_visible_icons(visibility[i]) end) + Enum.all?(counts, fn count -> count == List.first(counts) end) + end end From 567396d626aed3fd2492fe3de3fb99bdef6edd9e Mon Sep 17 00:00:00 2001 From: Nuno Miguel Date: Mon, 3 Feb 2025 17:57:42 +0000 Subject: [PATCH 06/12] feat: paytable modal --- assets/js/app.js | 3 +- assets/js/hooks/index.js | 3 +- assets/js/hooks/paytable_modal.js | 16 +++ .../{ReelAnimation.js => reel_animation.js} | 0 .../slots_live/components/paytable_modal.ex | 134 ++++++++++++++++++ lib/safira_web/live/app/slots_live/index.ex | 1 + .../live/app/slots_live/index.html.heex | 21 ++- lib/safira_web/router.ex | 5 +- 8 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 assets/js/hooks/paytable_modal.js rename assets/js/hooks/{ReelAnimation.js => reel_animation.js} (100%) create mode 100644 lib/safira_web/live/app/slots_live/components/paytable_modal.ex diff --git a/assets/js/app.js b/assets/js/app.js index 6f1013e8e..ebc6e8570 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,7 +22,7 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import live_select from "live_select" -import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene , Banner, ReelAnimation } from "./hooks"; +import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene , Banner, ReelAnimation, PaytableModal } from "./hooks"; let Hooks = { QrScanner: QrScanner, @@ -35,6 +35,7 @@ let Hooks = { Redirect: Redirect, CredentialScene: CredentialScene, ReelAnimation: ReelAnimation, + PaytableModal: PaytableModal, ...live_select }; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index 85a7b51a3..2b5ca5880 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -7,4 +7,5 @@ export { Countdown } from "./countdown.js"; export { CoinFlip } from "./coinflip.js"; export { Redirect } from "./redirect.js"; export { CredentialScene } from "./credential-scene.js"; -export { ReelAnimation } from "./ReelAnimation.js"; \ No newline at end of file +export { ReelAnimation } from "./reel_animation.js"; +export { PaytableModal } from "./paytable_modal.js"; \ No newline at end of file diff --git a/assets/js/hooks/paytable_modal.js b/assets/js/hooks/paytable_modal.js new file mode 100644 index 000000000..6e2fe7722 --- /dev/null +++ b/assets/js/hooks/paytable_modal.js @@ -0,0 +1,16 @@ +export const PaytableModal = { + mounted() { + // For every payline group in the modal, cycle through its items every second. + let groups = this.el.querySelectorAll(".payline-group"); + groups.forEach((group) => { + let items = group.querySelectorAll(".payline-item"); + if (items.length <= 1) return; + let idx = 0; + setInterval(() => { + items[idx].classList.add("hidden"); + idx = (idx + 1) % items.length; + items[idx].classList.remove("hidden"); + }, 1000); + }); + } +}; \ No newline at end of file diff --git a/assets/js/hooks/ReelAnimation.js b/assets/js/hooks/reel_animation.js similarity index 100% rename from assets/js/hooks/ReelAnimation.js rename to assets/js/hooks/reel_animation.js diff --git a/lib/safira_web/live/app/slots_live/components/paytable_modal.ex b/lib/safira_web/live/app/slots_live/components/paytable_modal.ex new file mode 100644 index 000000000..9ccd5a6ab --- /dev/null +++ b/lib/safira_web/live/app/slots_live/components/paytable_modal.ex @@ -0,0 +1,134 @@ +defmodule SafiraWeb.App.SlotsLive.Components.PaytableModal do + @moduledoc """ + Slots paytable modal component that shows winning combinations + """ + use SafiraWeb, :component + + alias Safira.Minigames + alias Safira.Uploaders.SlotsReelIcon + + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :wrapper_class, :string, default: "" + attr :on_cancel, JS, default: %JS{} + + def paytable_modal(assigns) do + paylines = Minigames.list_slots_paylines() + reel_icons = Minigames.list_slots_reel_icons() + + reel_0_icons = sort_reel_icons(reel_icons, :reel_0_index) + reel_1_icons = sort_reel_icons(reel_icons, :reel_1_index) + reel_2_icons = sort_reel_icons(reel_icons, :reel_2_index) + + reel_icons_map = %{ + 0 => index_icons_by_position(reel_0_icons, :reel_0_index), + 1 => index_icons_by_position(reel_1_icons, :reel_1_index), + 2 => index_icons_by_position(reel_2_icons, :reel_2_index) + } + + assigns = + assign(assigns, + paylines_by_multiplier: group_paylines_by_multiplier(paylines), + reel_icons_map: reel_icons_map + ) + + ~H""" +