Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: slots minigame #372

Merged
merged 5 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions data/slots.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
probability,multiplier
0.3,1.0
0.08,2.0
0.04,3.0
0.025,5.0
0.00499,10.0
0.00001,100.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 payout 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
24 changes: 24 additions & 0 deletions lib/safira/slots/payout.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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])
|> validate_number(:multiplier, greater_than: 0)
|> validate_number(:probability, greater_than: 0, less_than_or_equal_to: 1)
end
end
158 changes: 158 additions & 0 deletions lib/safira/slots/slots.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
defmodule Safira.Slots do
@moduledoc """
The Slots context.
"""

import Ecto.Query, warn: false

alias Ecto.Multi

alias Safira.Repo

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

@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_state, 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.sort_by(& &1.probability)
|> 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.sort_by(& &1.probability)
|> 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
42 changes: 42 additions & 0 deletions lib/safira_web/controllers/slots/slots_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 play the slots"})
else
case Integer.parse(bet) do
{bet, ""} ->
if bet > 0 do
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
else
conn
|> put_status(:bad_request)
|> json(%{error: "Bet should be a positive integer"})
end

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

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 @@ -63,6 +63,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
Loading
Loading