Skip to content

Commit

Permalink
feat: add slots minigame
Browse files Browse the repository at this point in the history
  • Loading branch information
joaodiaslobo committed Jan 27, 2024
1 parent 2d60e44 commit b2395b3
Show file tree
Hide file tree
Showing 12 changed files with 366 additions and 3 deletions.
6 changes: 6 additions & 0 deletions data/slots.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
probability,multiplier
0.00005,10.0
0.0001,7.0
0.003,3.0
0.009,2.0
0.05,1.0
59 changes: 59 additions & 0 deletions lib/mix/tasks/gen.slots_payouts.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
defmodule Mix.Tasks.Gen.Payouts do
@shortdoc "Generates slot machine payouts from a CSV"
@moduledoc """
This CSV is waiting for:
probability,multiplier
"""
use Mix.Task

alias NimbleCSV.RFC4180, as: CSV

def run(args) do
if Enum.empty?(args) do
Mix.shell().info("Needs to receive a file URL.")
else
args |> List.first() |> create
end
end

defp create(path) do
Mix.Task.run("app.start")

path
|> parse_csv()
|> validate_probabilities()
|> insert_payouts()
end

defp parse_csv(path) do
path
|> File.stream!()
|> CSV.parse_stream()
|> Enum.map(fn [probability, muliplier] ->
%{
probability: String.to_float(probability),
multiplier: String.to_float(muliplier)
}
end)
end

defp validate_probabilities(list) do
list
|> Enum.map_reduce(0, fn payout, acc -> {payout, payout.probability + acc} end)
|> case do
{_, x} ->
if x < 1 do
list
else
raise "The sum of all prizes probabilities is bigger 1."
end
end
end

defp insert_payouts(list) do
list
|> Enum.map(fn payout ->
Safira.Slots.create_payout(payout)
end)
end
end
6 changes: 3 additions & 3 deletions lib/safira/roulette/roulette.ex
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,9 @@ defmodule Safira.Roulette do
end

@doc """
Transaction that take a number of tokens from an attendee,
apply a probability-based function for "spinning the wheel",
and give the price to the attendee.
Transaction that takes a number of tokens from an attendee,
and applies a probability-based function for "spinning the wheel",
and give the prize to the attendee.
"""
def spin_transaction(attendee) do
Multi.new()
Expand Down
32 changes: 32 additions & 0 deletions lib/safira/slots/attendee_payout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Safira.Slots.AttendeePayout do
@moduledoc """
Intermediate schema to register slot payouts won by attendees or losses.
"""
use Ecto.Schema
import Ecto.Changeset

alias Safira.Accounts.Attendee
alias Safira.Slots.Payout

schema "attendees_payouts" do
field :bet, :integer
field :tokens, :integer

belongs_to :attendee, Attendee, foreign_key: :attendee_id, type: :binary_id
belongs_to :payout, Payout

timestamps()
end

@doc false
def changeset(attendee_prize, attrs) do
attendee_prize
|> cast(attrs, [:bet, :tokens, :attendee_id, :payout_id])
|> validate_required([:bet, :tokens, :attendee_id])
|> unique_constraint(:unique_attendee_payout)
|> validate_number(:bet, greater_than: 0)
|> validate_number(:tokens, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:attendee_id)
|> foreign_key_constraint(:payout_id)
end
end
22 changes: 22 additions & 0 deletions lib/safira/slots/payout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule Safira.Slots.Payout do
@moduledoc """
Payouts listed in the pay table that can be won by playing slots.
"""
use Ecto.Schema
use Arc.Ecto.Schema
import Ecto.Changeset

schema "payouts" do
field :probability, :float
# supports float multipliers like 1.5x
field :multiplier, :float

timestamps()
end

def changeset(payout, attrs) do
payout
|> cast(attrs, [:probability, :multiplier])
|> validate_required([:probability, :multiplier])
end
end
152 changes: 152 additions & 0 deletions lib/safira/slots/slots.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
defmodule Safira.Slots do

Check warning on line 1 in lib/safira/slots/slots.ex

View workflow job for this annotation

GitHub Actions / OTP 25.x / Elixir 1.14.x

Modules should have a @moduledoc tag.
import Ecto.Query, warn: false

alias Ecto.Multi

alias Safira.Repo

alias Safira.Contest
alias Safira.Contest.DailyToken
alias Safira.Slots.Payout
alias Safira.Accounts.Attendee
alias Safira.Slots.AttendeePayout

@doc """
Creates a payout.
## Examples
iex> create_payout(%{field: value})
{:ok, %Payout{}}
iex> create_payout(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_payout(attrs \\ %{}) do
%Payout{}
|> Payout.changeset(attrs)
|> Repo.insert()
end

def spin(attendee, bet) do
spin_transaction(attendee, bet)
|> case do
{:error, :attendee, changeset, data} ->
if Map.get(get_errors(changeset), :token_balance) != nil do
{:error, :not_enough_tokens}
else
{:error, :attendee, changeset, data}
end

result ->
result
end
end

@doc """
Transaction that takes a number of tokens bet by an attendee,
and applies a probability-based function for "spinning the reels on a slot machine"
that calculates a payout and then updates the attendee's token balance.
"""
def spin_transaction(attendee, bet) do
Multi.new()
# remove the bet from the attendee's token balance
|> Multi.update(
:attendee_state,
Attendee.update_token_balance_changeset(attendee, %{
token_balance: attendee.token_balance - bet
})
)
# generate a random payout
|> Multi.run(:payout, fn _repo, _changes -> {:ok, generate_spin()} end)
# calculate the tokens (if any)
|> Multi.run(:tokens, fn _repo, %{payout: payout} ->
{:ok, (bet * payout.multiplier) |> round}
end)
# log slots result for statistical purposes
|> Multi.insert(:attendee_payout, fn %{payout: payout, tokens: tokens} ->
%AttendeePayout{}
|> AttendeePayout.changeset(%{
attendee_id: attendee.id,
payout_id: payout.id,
bet: bet,
tokens: tokens
})
end)
# update user tokens based on the payout
|> Multi.update(:attendee, fn %{attendee_state: attendee, tokens: tokens} ->
Attendee.update_token_balance_changeset(attendee, %{
token_balance: attendee.token_balance + tokens
})
end)
# update the daily token count for leaderboard purposes
|> Multi.insert_or_update(:daily_token, fn %{attendee: attendee} ->
{:ok, date, _} = DateTime.from_iso8601("#{Date.utc_today()}T00:00:00Z")
changeset_daily = Contest.get_keys_daily_token(attendee.id, date) || %DailyToken{}

DailyToken.changeset(changeset_daily, %{
quantity: attendee.token_balance,
attendee_id: attendee.id,
day: date
})
end)
|> Repo.transaction()
end

# Generates a random payout, based on the probability of each multiplier
defp generate_spin do
random = strong_randomizer() |> Float.round(12)

payouts =
Repo.all(Payout)
|> Enum.filter(fn x -> x.probability > 0 end)

cumulative_prob =
payouts
|> Enum.map_reduce(0, fn payout, acc ->
{Float.round(acc + payout.probability, 12), acc + payout.probability}
end)

cumulatives =
cumulative_prob
|> elem(0)
|> Enum.concat([1])

sum =
cumulative_prob
|> elem(1)

remaining_prob = 1 - sum

real_payouts = payouts ++ [%{multiplier: 0, probability: remaining_prob, id: nil}]

prob =
cumulatives
|> Enum.filter(fn x -> x >= random end)
|> Enum.at(0)

real_payouts
|> Enum.at(
cumulatives
|> Enum.find_index(fn x -> x == prob end)
)
end

# Generates a random number using the Erlang crypto module
defp strong_randomizer do
<<i1::unsigned-integer-32, i2::unsigned-integer-32, i3::unsigned-integer-32>> =
:crypto.strong_rand_bytes(12)

:rand.seed(:exsplus, {i1, i2, i3})
:rand.uniform()
end

defp get_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end
36 changes: 36 additions & 0 deletions lib/safira_web/controllers/slots/slots_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule SafiraWeb.SlotsController do
use SafiraWeb, :controller

alias Safira.Accounts
alias Safira.Slots

action_fallback SafiraWeb.FallbackController

def spin(conn, %{"bet" => bet}) do
attendee = Accounts.get_user(conn) |> Map.fetch!(:attendee)

if is_nil(attendee) do
conn
|> put_status(:unauthorized)
|> json(%{error: "Only attendees can spin the wheel"})
else
case Integer.parse(bet) do
{bet, _} ->
case Slots.spin(attendee, bet) do
{:ok, outcome} ->
render(conn, :spin_result, outcome)

{:error, :not_enough_tokens} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Insufficient token balance"})
end

_ ->
conn
|> put_status(:bad_request)
|> json(%{error: "Bet should be an integer"})
end
end
end
end
11 changes: 11 additions & 0 deletions lib/safira_web/controllers/slots/slots_json.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule SafiraWeb.SlotsJSON do

Check warning on line 1 in lib/safira_web/controllers/slots/slots_json.ex

View workflow job for this annotation

GitHub Actions / OTP 25.x / Elixir 1.14.x

Modules should have a @moduledoc tag.
def spin_result(data) do
payout = Map.get(data, :payout)
tokens = Map.get(data, :tokens)

%{
multiplier: payout.multiplier,
tokens: tokens
}
end
end
1 change: 1 addition & 0 deletions lib/safira_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ defmodule SafiraWeb.Router do
post "/spotlight", SpotlightController, :create
post "/store/redeem", DeliverRedeemableController, :create
post "/roulette/redeem", DeliverPrizeController, :create
post "/slots", SlotsController, :spin

delete "/roulette/redeem/:badge_id/:user_id", DeliverPrizeController, :delete

Expand Down
14 changes: 14 additions & 0 deletions priv/repo/migrations/20240120000345_create_payouts.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule Safira.Repo.Migrations.CreatePayouts do
use Ecto.Migration

def change do
create table(:payouts) do
add :probability, :float
add :multiplier, :float

timestamps()
end

create unique_index(:payouts, [:multiplier])
end
end
17 changes: 17 additions & 0 deletions priv/repo/migrations/20240120001336_create_attendees_payouts.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule Safira.Repo.Migrations.CreateAttendeesPayouts do
use Ecto.Migration

def change do
create table(:attendees_payouts) do
add :attendee_id, references(:attendees, on_delete: :delete_all, type: :uuid)
add :payout_id, references(:payouts, on_delete: :delete_all)
add :bet, :integer
add :tokens, :integer

timestamps()
end

create index(:attendees_payouts, [:attendee_id])
create index(:attendees_payouts, [:payout_id])
end
end
13 changes: 13 additions & 0 deletions priv/repo/seeds/slots.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Safira.Repo.Seeds.Slots do
alias Mix.Tasks.Gen.Payouts

def run do
seed_payouts()
end

defp seed_payouts do
Payouts.run(["data/slots.csv"])
end
end

Safira.Repo.Seeds.Slots.run()

0 comments on commit b2395b3

Please sign in to comment.