diff --git a/apps/champions/lib/champions/battle.ex b/apps/champions/lib/champions/battle.ex index 8110fcec5..40609829c 100644 --- a/apps/champions/lib/champions/battle.ex +++ b/apps/champions/lib/champions/battle.ex @@ -5,6 +5,7 @@ defmodule Champions.Battle do require Logger + alias GameBackend.Ledger alias Ecto.Multi alias GameBackend.Users.Currencies alias Champions.Battle.Simulator @@ -36,9 +37,7 @@ defmodule Champions.Battle do {:ok, response} = Multi.new() - |> Multi.run(:substract_currencies, fn _repo, _changes -> - Currencies.substract_currencies(user_id, level.attempt_cost) - end) + |> Ledger.register_currencies_spent(user_id, level.attempt_cost, "Fighting Level") |> Multi.run(:run_battle, fn _repo, _changes -> run_battle(user_id, level, units) end) |> Repo.transaction() diff --git a/apps/champions/lib/champions/campaigns.ex b/apps/champions/lib/champions/campaigns.ex index cd99569b2..82217dc5f 100644 --- a/apps/champions/lib/champions/campaigns.ex +++ b/apps/champions/lib/champions/campaigns.ex @@ -3,12 +3,12 @@ defmodule Champions.Campaigns do Campaigns logic for Champions of Mirra. """ + alias GameBackend.Ledger alias GameBackend.Campaigns alias GameBackend.Campaigns.SuperCampaignProgress alias GameBackend.Items.Item alias GameBackend.Repo alias GameBackend.Units.Unit - alias GameBackend.Users.Currencies alias Ecto.Multi @doc """ @@ -117,10 +117,6 @@ defmodule Champions.Campaigns do end defp apply_currency_rewards(multi, user_id, currency_rewards) do - Enum.reduce(currency_rewards, multi, fn currency_reward, multi -> - Multi.run(multi, {:add_currency, currency_reward.currency_id}, fn _, _ -> - Currencies.add_currency(user_id, currency_reward.currency_id, currency_reward.amount) - end) - end) + Ledger.register_currency_earned(multi, user_id, currency_rewards, "Campaign Rewards") end end diff --git a/apps/champions/lib/champions/gacha.ex b/apps/champions/lib/champions/gacha.ex index 38c2c8571..a4a0a6936 100644 --- a/apps/champions/lib/champions/gacha.ex +++ b/apps/champions/lib/champions/gacha.ex @@ -3,6 +3,7 @@ defmodule Champions.Gacha do Gacha logic for Champions of Mirra. """ + alias GameBackend.Ledger alias GameBackend.Gacha alias GameBackend.Transaction alias GameBackend.Users @@ -44,9 +45,7 @@ defmodule Champions.Gacha do result = Multi.new() |> Multi.run(:unit, fn _, _ -> Units.insert_unit(params) end) - |> Multi.run(:substract_currencies, fn _, _ -> - Currencies.substract_currencies(user_id, box.cost) - end) + |> Ledger.register_currencies_spent(user_id, box.cost, "Summoned Box Cost") |> Transaction.run() case result do diff --git a/apps/champions/lib/champions/items.ex b/apps/champions/lib/champions/items.ex index 7f74833f7..b68afee81 100644 --- a/apps/champions/lib/champions/items.ex +++ b/apps/champions/lib/champions/items.ex @@ -3,6 +3,7 @@ defmodule Champions.Items do Items logic for Champions of Mirra. """ + alias GameBackend.Ledger alias Ecto.Multi alias GameBackend.Items alias GameBackend.Transaction @@ -55,9 +56,7 @@ defmodule Champions.Items do Multi.new() |> Multi.run(:item, fn _, _ -> Items.insert_item(%{user_id: user_id, template_id: new_template.id}) end) |> Multi.run(:deleted_items, fn _, _ -> delete_consumed_items(consumed_items_ids) end) - |> Multi.run(:currency_deduction, fn _, _ -> - Currencies.substract_currencies(user_id, new_template.upgrade_costs) - end) + |> Ledger.register_currencies_spent(user_id, new_template.upgrade_costs, "Fuse item") |> Transaction.run() case result do diff --git a/apps/champions/lib/champions/units.ex b/apps/champions/lib/champions/units.ex index 6274a5610..6e75441ce 100644 --- a/apps/champions/lib/champions/units.ex +++ b/apps/champions/lib/champions/units.ex @@ -7,6 +7,7 @@ defmodule Champions.Units do - Tier ups cost an amount of gold that depends on the unit level, plus a set number of gems. """ + alias GameBackend.Ledger alias GameBackend.Utils alias Ecto.Multi alias GameBackend.Transaction @@ -89,9 +90,7 @@ defmodule Champions.Units do result = Multi.new() |> Multi.run(:unit, fn _, _ -> Units.add_level(unit) end) - |> Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, costs) - end) + |> Ledger.register_currencies_spent(user_id, costs, "Level Up Unit") |> Transaction.run() case result do @@ -165,9 +164,7 @@ defmodule Champions.Units do result = Multi.new() |> Multi.run(:unit, fn _, _ -> Units.add_tier(unit) end) - |> Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, costs) - end) + |> Ledger.register_currencies_spent(user_id, costs, "Tier up") |> GameBackend.Transaction.run() case result do diff --git a/apps/champions/lib/champions/users.ex b/apps/champions/lib/champions/users.ex index 64d68dfbe..74ffc79c9 100644 --- a/apps/champions/lib/champions/users.ex +++ b/apps/champions/lib/champions/users.ex @@ -3,6 +3,7 @@ defmodule Champions.Users do Users logic for Champions Of Mirra. """ + alias GameBackend.Ledger alias Ecto.{Changeset, Multi} alias Champions.Users alias GameBackend.Items @@ -91,52 +92,45 @@ defmodule Champions.Users do end defp add_sample_currencies(user) do - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Gold", Utils.get_game_id(:champions_of_mirra)).id, - 100 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Gems", Utils.get_game_id(:champions_of_mirra)).id, - 500 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Summon Scrolls", Utils.get_game_id(:champions_of_mirra)).id, - 100 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Fertilizer", Utils.get_game_id(:champions_of_mirra)).id, - 100 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Arcane Crystals", Utils.get_game_id(:champions_of_mirra)).id, - 100 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Hero Souls", Utils.get_game_id(:champions_of_mirra)).id, - 100 - ) - - Currencies.add_currency( - user.id, - Currencies.get_currency_by_name_and_game!("Blueprints", Utils.get_game_id(:champions_of_mirra)).id, - 50 - ) + game_id = Utils.get_game_id(:champions_of_mirra) - Currencies.add_currency( + Ledger.register_currency_earned( user.id, - Currencies.get_currency_by_name_and_game!("Supplies", Utils.get_game_id(:champions_of_mirra)).id, - 5 + [ + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Gold", game_id).id, + amount: 100 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Gems", game_id).id, + amount: 500 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Summon Scrolls", game_id).id, + amount: 100 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Fertilizer", game_id).id, + amount: 100 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Arcane Crystals", game_id).id, + amount: 100 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Hero Souls", game_id).id, + amount: 100 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Blueprints", game_id).id, + amount: 50 + }, + %{ + currency_id: Currencies.get_currency_by_name_and_game!("Supplies", game_id).id, + amount: 5 + } + ], + "Sample Currencies" ) end @@ -247,18 +241,13 @@ defmodule Champions.Users do defp claim_afk_rewards(user_id, afk_rewards, type) do Multi.new() - |> Multi.run(:add_currencies, fn _, _ -> - results = - Enum.map(afk_rewards, fn afk_reward -> - Currencies.add_currency(user_id, afk_reward.currency.id, trunc(afk_reward.amount)) - end) - - if Enum.all?(results, fn {result, _} -> result == :ok end) do - {:ok, Enum.map(results, fn {_ok, currency} -> currency end)} - else - {:error, "failed"} - end - end) + |> Ledger.register_currency_earned( + user_id, + Enum.map(afk_rewards, fn afk_reward -> + %{currency_id: afk_reward.currency.id, amount: trunc(afk_reward.amount)} + end), + "AFK Reward Claimed" + ) |> Multi.run(:reset_afk_claim, fn _, _ -> Users.reset_afk_rewards_claim(user_id, type) end) diff --git a/apps/game_backend/lib/game_backend/curse_of_mirra/matches.ex b/apps/game_backend/lib/game_backend/curse_of_mirra/matches.ex index 02fd38a76..071d3b75b 100644 --- a/apps/game_backend/lib/game_backend/curse_of_mirra/matches.ex +++ b/apps/game_backend/lib/game_backend/curse_of_mirra/matches.ex @@ -2,6 +2,7 @@ defmodule GameBackend.CurseOfMirra.Matches do @moduledoc """ Matches """ + alias GameBackend.Ledger alias GameBackend.Users.Currencies alias GameBackend.Units alias GameBackend.Units.Unit @@ -96,7 +97,11 @@ defmodule GameBackend.CurseOfMirra.Matches do if Map.has_key?(@gold_rewards_per_position, resulting_position) do gold_reward_amount = Map.get(@gold_rewards_per_position, resulting_position) - Currencies.add_currency(user.id, gold_currency.id, gold_reward_amount) + Ledger.register_currency_earned( + user.id, + [%{currency_id: gold_currency.id, amount: gold_reward_amount}], + "Match Reward" + ) else {:ok, nil} end diff --git a/apps/game_backend/lib/game_backend/curse_of_mirra/quests.ex b/apps/game_backend/lib/game_backend/curse_of_mirra/quests.ex index d26f41fc0..fa797e8d9 100644 --- a/apps/game_backend/lib/game_backend/curse_of_mirra/quests.ex +++ b/apps/game_backend/lib/game_backend/curse_of_mirra/quests.ex @@ -2,6 +2,7 @@ defmodule GameBackend.CurseOfMirra.Quests do @moduledoc """ Module to work with quest logic """ + alias GameBackend.Ledger alias GameBackend.Users alias GameBackend.CurseOfMirra.Quests alias GameBackend.Utils @@ -256,9 +257,7 @@ defmodule GameBackend.CurseOfMirra.Quests do true -> Multi.new() - |> Multi.run(:deduct_currencies, fn _, _ -> - Currencies.substract_currencies(daily_quest.user_id, reroll_costs) - end) + |> Ledger.register_currencies_spent(daily_quest.user_id, reroll_costs, "Reroll Quest") |> Multi.update(:change_previous_quest, finish_previous_quest_changeset) |> Multi.insert(:insert_quest, new_quest_changeset) |> Repo.transaction() @@ -282,16 +281,15 @@ defmodule GameBackend.CurseOfMirra.Quests do quest_id: quest_id }) + currency = Currencies.get_currency_by_name_and_game(quest.reward["currency"], Utils.get_game_id(:curse_of_mirra)) + Multi.new() |> Multi.insert(:insert_completed_quest, user_quest_changeset) - |> Multi.run(:add_currency_to_user, fn _, _ -> - Currencies.add_currency_by_name_and_game( - user_id, - quest.reward["currency"], - Utils.get_game_id(:curse_of_mirra), - quest.reward["amount"] - ) - end) + |> Ledger.register_currency_earned( + user_id, + [%{currency_id: currency.id, amount: quest.reward["amount"]}], + "Completed Quest Reward" + ) |> Repo.transaction() end @@ -340,6 +338,9 @@ defmodule GameBackend.CurseOfMirra.Quests do status: "completed" }) + currency = + Currencies.get_currency_by_name_and_game(user_quest.reward["currency"], Utils.get_game_id(:curse_of_mirra)) + Multi.new() |> Multi.run(:check_quest_completed, fn _, _ -> if user_quest.status == "available" and Quests.completed_quest?(user_quest, user) do @@ -363,14 +364,11 @@ defmodule GameBackend.CurseOfMirra.Quests do |> GameBackend.Repo.update() end end) - |> Multi.run(:add_currency, fn _, _ -> - Currencies.add_currency_by_name_and_game( - user.id, - user_quest.quest.reward["currency"], - Utils.get_game_id(:curse_of_mirra), - user_quest.quest.reward["amount"] - ) - end) + |> Ledger.register_currency_earned( + user.id, + [%{currency_id: currency.id, amount: user_quest.reward["amount"]}], + "Completed Quest Reward" + ) |> Multi.run(:updated_user, fn _, _ -> Users.get_user_by_id_and_game_id(user.id, user.game_id) end) diff --git a/apps/game_backend/lib/game_backend/items.ex b/apps/game_backend/lib/game_backend/items.ex index 354fe264c..cc726f92e 100644 --- a/apps/game_backend/lib/game_backend/items.ex +++ b/apps/game_backend/lib/game_backend/items.ex @@ -8,11 +8,11 @@ defmodule GameBackend.Items do Items are created by instantiating copies of ItemTemplates. This way, many users can have their own copy of the "Epic Sword" item. Likewise, this allows for a user to have many copies of it, each equipped to a different unit. """ + alias GameBackend.Ledger alias Ecto.Multi alias GameBackend.Items.Item alias GameBackend.Items.ItemTemplate alias GameBackend.Items.ConsumableItem - alias GameBackend.Users.Currencies alias GameBackend.Repo alias GameBackend.Units @@ -272,7 +272,7 @@ defmodule GameBackend.Items do def buy_item(user_id, template_id, purchase_costs_list) do Multi.new() |> Multi.run(:item, fn _, _ -> insert_item(%{user_id: user_id, template_id: template_id}) end) - |> Multi.run(:currencies, fn _, _ -> Currencies.substract_currencies(user_id, purchase_costs_list) end) + |> Ledger.register_currencies_spent(user_id, purchase_costs_list, "Item Bought") |> Repo.transaction() end diff --git a/apps/game_backend/lib/game_backend/ledger.ex b/apps/game_backend/lib/game_backend/ledger.ex new file mode 100644 index 000000000..4dfec705f --- /dev/null +++ b/apps/game_backend/lib/game_backend/ledger.ex @@ -0,0 +1,203 @@ +defmodule GameBackend.Ledger do + @moduledoc """ + """ + + import Ecto.Query + alias GameBackend.Users.Currencies.UserCurrencyCap + alias GameBackend.Users.Currencies.UserCurrency + alias GameBackend.Ledger.Transaction + alias Ecto.Multi + alias GameBackend.Repo + + def get_user_transactions(user_id) do + q = + from(t in Transaction, + where: t.user_id == ^user_id, + order_by: [desc: :inserted_at] + ) + + Repo.all(q) + end + + # + # Returns the current balance for a given user of all currencies. Returns a list of tuples where the first element is the currency id and the second one is the balance of that currency + # This can be computed on-the-fly or have this cached in each UserCurrency. + # + def get_currency_balances(user_id) do + q = + from(t in Transaction, + where: t.user_id == ^user_id, + group_by: t.currency_id, + select: {t.currency_id, fragment("SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END)")} + ) + + Repo.all(q) + end + + # + # Returns the current balance for a given user of a specific currency + # This can be computed on-the-fly or have this cached in UserCurrency. + # + def get_balance(user_id, currency_id) do + q = + from(t in Transaction, + where: t.user_id == ^user_id and t.currency_id == ^currency_id, + group_by: t.currency_id, + select: fragment("SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END)") + ) + + Repo.one(q) + end + + @doc """ + Returns an `Ecto.Multi` that will perform all checks, substract the specified + currencies to the user and adds a register to the transaction log. + + To ensure transaction isolation you can set the isolation level with: + + Multi.run(:set_serializable_step, fn repo, _ -> + # This is needed because in tests we run inside a transaction, + # nesting transactions or changing isolation level doesn't work + if Application.get_env(:game_backend, :env) == :test do + {:ok, :skip} + else + {:ok, repo.query!("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")} + end + end) + + """ + def register_currencies_spent_multi(multi, user_id, currency_costs, description) do + Enum.reduce(currency_costs, multi, fn currency_cost, acc -> + transaction_changeset = + %Transaction{} + |> Transaction.changeset(%{ + user_id: user_id, + currency_id: currency_cost.currency_id, + type: :debit, + amount: currency_cost.amount, + description: description, + timestamp: DateTime.utc_now() + }) + + acc + |> Multi.run({:user_currency, currency_cost.currency_id}, fn repo, _changes -> + {:ok, repo.get_by(UserCurrency, user_id: user_id, currency_id: currency_cost.currency_id)} + end) + |> Multi.run({:has_enough_currency?, currency_cost.currency_id}, fn _repo, changes -> + user_currency = Map.get(changes, {:user_currency, currency_cost.currency_id}) + + if not is_nil(user_currency) and user_currency.amount >= currency_cost.amount do + {:ok, true} + else + {:error, :not_enough_currency} + end + end) + |> Multi.update({:remove_currency_from_user, currency_cost.currency_id}, fn changes -> + user_currency = Map.get(changes, {:user_currency, currency_cost.currency_id}) + Ecto.Changeset.change(user_currency, amount: user_currency.amount - currency_cost.amount) + end) + |> Multi.insert({:insert_currency_removal_into_ledger, currency_cost.currency_id}, transaction_changeset) + end) + end + + def register_currencies_spent(user_id, currency_costs, description) do + register_currencies_spent_multi(Multi.new(), user_id, currency_costs, description) + |> Repo.transaction() + end + + def register_currencies_spent(multi, user_id, currency_costs, description) do + Multi.new() + |> Multi.run(:set_serializable_step, fn repo, _ -> + # This is needed because in tests we run inside a transaction, + # nesting transactions or changing isolation level doesn't work + if Application.get_env(:game_backend, :env) == :test do + {:ok, :skip} + else + {:ok, repo.query!("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")} + end + end) + |> Multi.merge(fn _ -> multi end) + |> register_currencies_spent_multi(user_id, currency_costs, description) + end + + @doc """ + Returns an `Ecto.Multi` that will perform all checks, add the specified + currencies to the user and adds a register to the transaction log. + + To ensure transaction isolation you can set the isolation level with: + + Multi.run(:set_serializable_step, fn repo, _ -> + # This is needed because in tests we run inside a transaction, + # nesting transactions or changing isolation level doesn't work + if Application.get_env(:game_backend, :env) == :test do + {:ok, :skip} + else + {:ok, repo.query!("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")} + end + end) + + """ + def register_currency_earned_multi(multi, user_id, earned_currencies, description) do + Enum.reduce(earned_currencies, multi, fn currency_earned, acc -> + transaction_changeset = + %Transaction{} + |> Transaction.changeset(%{ + user_id: user_id, + currency_id: currency_earned.currency_id, + type: :credit, + amount: currency_earned.amount, + description: description, + timestamp: DateTime.utc_now() + }) + + acc + |> Multi.run({:user_currency, currency_earned.currency_id}, fn repo, _changes -> + user_currency = repo.get_by(UserCurrency, user_id: user_id, currency_id: currency_earned.currency_id) + + if is_nil(user_currency) do + %UserCurrency{} + |> UserCurrency.changeset(%{user_id: user_id, currency_id: currency_earned.currency_id, amount: 0}) + |> repo.insert() + else + {:ok, user_currency} + end + end) + |> Multi.run({:user_currency_cap, currency_earned.currency_id}, fn repo, _changes -> + {:ok, repo.get_by(UserCurrencyCap, user_id: user_id, currency_id: currency_earned.currency_id)} + end) + |> Multi.update({:add_currency_to_user, currency_earned.currency_id}, fn changes -> + user_currency = Map.get(changes, {:user_currency, currency_earned.currency_id}) + user_currency_cap = Map.get(changes, {:user_currency_cap, currency_earned.currency_id}) + + case user_currency_cap do + nil -> + Ecto.Changeset.change(user_currency, amount: user_currency.amount + currency_earned.amount) + + %UserCurrencyCap{cap: cap} -> + Ecto.Changeset.change(user_currency, amount: min(user_currency.amount + currency_earned.amount, cap)) + end + end) + |> Multi.insert({:insert_currency_income_into_ledger, currency_earned.currency_id}, transaction_changeset) + end) + end + + def register_currency_earned(user_id, earned_currencies, description) do + register_currency_earned_multi(Multi.new(), user_id, earned_currencies, description) + |> Repo.transaction() + end + + def register_currency_earned(multi, user_id, currency_costs, description) do + Multi.new() + |> Multi.run(:set_serializable_step, fn repo, _ -> + # This is needed because in tests we run inside a transaction, + # nesting transactions or changing isolation level doesn't work + if Application.get_env(:game_backend, :env) == :test do + {:ok, :skip} + else + {:ok, repo.query!("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")} + end + end) + |> Multi.merge(fn _ -> multi end) + |> register_currency_earned_multi(user_id, currency_costs, description) + end +end diff --git a/apps/game_backend/lib/game_backend/ledger/transaction.ex b/apps/game_backend/lib/game_backend/ledger/transaction.ex new file mode 100644 index 000000000..a178defc6 --- /dev/null +++ b/apps/game_backend/lib/game_backend/ledger/transaction.ex @@ -0,0 +1,33 @@ +defmodule GameBackend.Ledger.Transaction do + @moduledoc """ + The ledger will be used to keep track of a transaction log of each game + """ + use GameBackend.Schema + import Ecto.Changeset + + alias GameBackend.Users.User + alias GameBackend.Users.Currencies.Currency + + schema "ledger_transactions" do + field(:type, Ecto.Enum, values: [:credit, :debit]) + field(:amount, :integer) + field(:description, :string) + + belongs_to(:user, User) + belongs_to(:currency, Currency) + + timestamps(type: :utc_datetime) + end + + def changeset(transaction, attrs) do + transaction + |> cast(attrs, [ + :type, + :amount, + :description, + :user_id, + :currency_id + ]) + |> validate_required([:type, :amount, :description, :user_id, :currency_id]) + end +end diff --git a/apps/game_backend/lib/game_backend/rewards.ex b/apps/game_backend/lib/game_backend/rewards.ex index 4822b5a9f..fb2cda307 100644 --- a/apps/game_backend/lib/game_backend/rewards.ex +++ b/apps/game_backend/lib/game_backend/rewards.ex @@ -4,6 +4,7 @@ defmodule GameBackend.Rewards do """ import Ecto.Query + alias GameBackend.Ledger alias Ecto.Multi alias GameBackend.Repo alias GameBackend.Campaigns.Rewards.AfkRewardRate @@ -125,6 +126,8 @@ defmodule GameBackend.Rewards do Returns {:error, failed_operation, failed_value, changes_so_far} if one of the operations fail. """ def update_user_due_to_daily_rewards_claim(user, daily_reward) do + currency = Currencies.get_currency_by_name_and_game(daily_reward["currency"], Utils.get_game_id(:curse_of_mirra)) + Multi.new() |> Multi.run(:update_user, fn _, _ -> Users.update_user(user, %{ @@ -132,14 +135,11 @@ defmodule GameBackend.Rewards do last_daily_reward_claim: daily_reward["day"] }) end) - |> Multi.run(:update_user_currencies, fn _, _ -> - Currencies.add_currency_by_name_and_game!( - user.id, - daily_reward["currency"], - Utils.get_game_id(:curse_of_mirra), - daily_reward["amount"] - ) - end) + |> Ledger.register_currency_earned( + user.id, + [%{currency_id: currency.id, amount: daily_reward["amount"]}], + "Daily Reward Claim" + ) |> Repo.transaction() end end diff --git a/apps/game_backend/lib/game_backend/units.ex b/apps/game_backend/lib/game_backend/units.ex index beeddf092..98498207a 100644 --- a/apps/game_backend/lib/game_backend/units.ex +++ b/apps/game_backend/lib/game_backend/units.ex @@ -12,6 +12,7 @@ defmodule GameBackend.Units do import Ecto.Query + alias GameBackend.Ledger alias Ecto.Multi alias GameBackend.Configuration alias GameBackend.Repo @@ -326,9 +327,7 @@ defmodule GameBackend.Units do {:can_afford, true} <- {:can_afford, Currencies.can_afford(user_id, costs)} do Multi.new() |> Multi.run(:unit, fn _, _ -> add_level(unit) end) - |> Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, costs) - end) + |> Ledger.register_currencies_spent(user_id, costs, "Level Up") |> Transaction.run() end end diff --git a/apps/game_backend/lib/game_backend/units/characters.ex b/apps/game_backend/lib/game_backend/units/characters.ex index 0941332b3..128026514 100644 --- a/apps/game_backend/lib/game_backend/units/characters.ex +++ b/apps/game_backend/lib/game_backend/units/characters.ex @@ -4,10 +4,10 @@ defmodule GameBackend.Units.Characters do """ import Ecto.Query + alias GameBackend.Ledger alias GameBackend.Configuration.Version alias Ecto.Multi alias GameBackend.Repo - alias GameBackend.Users.Currencies alias GameBackend.Units.UnitSkin alias GameBackend.Units.Characters.Character alias GameBackend.Units.Characters.Skin @@ -256,7 +256,7 @@ defmodule GameBackend.Units.Characters do Multi.new() |> Multi.run(:unit_skin, fn _, _ -> insert_unit_skin(%{user_id: user_id, skin_id: skin_id, unit_id: unit_id}) end) - |> Multi.run(:currencies, fn _, _ -> Currencies.substract_currencies(user_id, purchase_costs_list) end) + |> Ledger.register_currencies_spent(user_id, purchase_costs_list, "Skin Bought") |> Multi.run(:updated_user, fn _, _ -> GameBackend.Users.get_user_by_id_and_game_id(user_id, curse_id) end) |> Repo.transaction() end diff --git a/apps/game_backend/lib/game_backend/users.ex b/apps/game_backend/lib/game_backend/users.ex index 17019d5e9..82a65c22b 100644 --- a/apps/game_backend/lib/game_backend/users.ex +++ b/apps/game_backend/lib/game_backend/users.ex @@ -10,6 +10,7 @@ defmodule GameBackend.Users do """ import Ecto.Query, warn: false + alias GameBackend.Ledger alias GameBackend.CurseOfMirra.Quests alias Ecto.Multi alias GameBackend.CurseOfMirra.Users, as: CurseUsers @@ -415,9 +416,7 @@ defmodule GameBackend.Users do {:ok, _result} = Multi.new() |> Multi.run(:user, fn _, _ -> increment_tree_level(user_id) end) - |> Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, level_up_costs) - end) + |> Ledger.register_currencies_spent(user_id, level_up_costs, "Kaline Tree Leveled up") |> Repo.transaction() get_user(user_id) @@ -446,9 +445,7 @@ defmodule GameBackend.Users do result = Multi.new() |> Multi.run(:user, fn _, _ -> increment_settlement_level(user_id) end) - |> Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, level_up_costs) - end) + |> Ledger.register_currencies_spent(user_id, level_up_costs, "Level Up") |> Multi.run(:supply_cap, fn _, %{user: user} -> dungeon_settlement_level = Repo.get(DungeonSettlementLevel, user.dungeon_settlement_level_id) @@ -545,9 +542,7 @@ defmodule GameBackend.Users do |> Multi.run(:upgrade, fn _, _ -> insert_unlock(%{user_id: user_id, upgrade_id: upgrade_id, name: upgrade.name, type: type}) end) - |> Multi.run(:substract_currencies, fn _, _ -> - Currencies.substract_currencies(user_id, upgrade.cost) - end) + |> Ledger.register_currencies_spent(user_id, upgrade.cost, "Upgrade Bought") |> Transaction.run() |> case do {:ok, _} -> {:ok, get_user(user_id)} diff --git a/apps/game_backend/lib/game_backend/users/currencies.ex b/apps/game_backend/lib/game_backend/users/currencies.ex index 7fac3bee3..53d4c2e56 100644 --- a/apps/game_backend/lib/game_backend/users/currencies.ex +++ b/apps/game_backend/lib/game_backend/users/currencies.ex @@ -77,47 +77,6 @@ defmodule GameBackend.Users.Currencies do ) ) || 0 - @doc """ - Adds (or substracts) the given amount of currency to a user. - Creates the relational table if it didn't exist previously. - """ - def add_currency(user_id, currency_id, amount) do - result = - case get_user_currency(user_id, currency_id) do - %UserCurrency{} = user_currency -> - new_amount = - case get_user_currency_cap(user_id, currency_id) do - %UserCurrencyCap{cap: cap} -> - if cap <= user_currency.amount do - # Cap reached, don't add anything. - user_currency.amount - else - # Cap not reached, add the amount. We are fine with allowing overflows. - user_currency.amount + amount - end - - nil -> - # No cap, just add the amount. - user_currency.amount + amount - end - # We don't want users with negative currencies - |> max(0) - - user_currency - |> UserCurrency.update_changeset(%{amount: new_amount}) - |> Repo.update() - - nil -> - # User has none of this currency, create it with given amount - insert_user_currency(%{user_id: user_id, currency_id: currency_id, amount: max(amount, 0)}) - end - - case result do - {:error, reason} -> {:error, reason} - {:ok, currency} -> {:ok, currency |> Repo.preload([:currency])} - end - end - @doc """ Get a UserCurrency. """ @@ -129,12 +88,6 @@ defmodule GameBackend.Users.Currencies do ) ) - defp insert_user_currency(attrs) do - %UserCurrency{} - |> UserCurrency.changeset(attrs) - |> Repo.insert() - end - @doc """ Returns whether the user can afford the required amounts of the specified currencies. """ @@ -152,43 +105,6 @@ defmodule GameBackend.Users.Currencies do user_balance >= required_amount end - @doc """ - Substracts all CurrencyCosts from the user. - - If all calls succeed, `{:ok, results}` is returned, where `results` is a %UserCurrency{} list. - If any of the calls fail, `{:error, "failed"}` is returned instead. - - Note that on failure, the succesful calls still take effect. Because of this, it's heavily - advised that you use this function inside a Multi transaction, specially if you are combining - it with other DB acesses. - - ## Examples - - iex> Ecto.Multi.new() - |> Ecto.Multi.run(:some_other_operation, fn _, _ -> other_operation() end) - |> Ecto.Multi.run(:user_currency, fn _, _ -> - Currencies.substract_currencies(user_id, [ - %CurrencyCost{currency_id: currency_id, amount: amount} - ]) - end) - |> GameBackend.Repo.transaction() - {:ok, %{user_currency: [%UserCurrency{}]} - """ - def substract_currencies(_user_id, []), do: {:ok, []} - - def substract_currencies(user_id, costs) do - results = - Enum.map(costs, fn %CurrencyCost{currency_id: currency_id, amount: cost} -> - add_currency(user_id, currency_id, -cost) - end) - - if Enum.all?(results, fn {result, _} -> result == :ok end) do - {:ok, Enum.map(results, fn {_ok, currency} -> currency end)} - else - {:error, "failed"} - end - end - @doc """ Gets how much a user has of a given currency by its name. """ @@ -202,34 +118,6 @@ defmodule GameBackend.Users.Currencies do ) || 0 end - @doc """ - Add amount of currency to user by its name. - """ - def add_currency_by_name_and_game!(user_id, currency_name, game_id, amount), - do: - user_id - |> add_currency(get_currency_by_name_and_game!(currency_name, game_id).id, amount) - - @doc """ - Add the specified amount of currency to an user by it's name - - Returns nil if the currency doesn't exists - - ## Examples - - iex> add_currency_by_name_and_game(user_id, currency_name, game_id, amount) - %UserCurrency{} - - iex> add_currency_by_name_and_game(user_id, currency_name, game_id, amount) - nil - """ - def add_currency_by_name_and_game(user_id, currency_name, game_id, amount) do - case get_currency_by_name_and_game(currency_name, game_id) do - {:error, :not_found} -> nil - {:ok, currency} -> add_currency(user_id, currency.id, amount) - end - end - @doc """ Inserts an UserCurrencyCap. """ diff --git a/apps/game_backend/priv/repo/migrations/20250422163658_add_ledger_transaction.exs b/apps/game_backend/priv/repo/migrations/20250422163658_add_ledger_transaction.exs new file mode 100644 index 000000000..a69d28a2c --- /dev/null +++ b/apps/game_backend/priv/repo/migrations/20250422163658_add_ledger_transaction.exs @@ -0,0 +1,15 @@ +defmodule GameBackend.Repo.Migrations.AddLedgerTransaction do + use Ecto.Migration + + def change do + create table(:ledger_transactions) do + add(:type, :string) + add(:amount, :integer) + add(:description, :string) + add(:user_id, references(:users)) + add(:currency_id, references(:currencies)) + + timestamps(type: :utc_datetime) + end + end +end diff --git a/apps/gateway/lib/gateway/controllers/curse_of_mirra/currency_controller.ex b/apps/gateway/lib/gateway/controllers/curse_of_mirra/currency_controller.ex index 615d23a91..83bd4ca23 100644 --- a/apps/gateway/lib/gateway/controllers/curse_of_mirra/currency_controller.ex +++ b/apps/gateway/lib/gateway/controllers/curse_of_mirra/currency_controller.ex @@ -5,6 +5,7 @@ defmodule Gateway.Controllers.CurseOfMirra.CurrencyController do use Gateway, :controller + alias GameBackend.Ledger alias GameBackend.Users alias GameBackend.Utils alias GameBackend.Users.Currencies @@ -18,9 +19,15 @@ defmodule Gateway.Controllers.CurseOfMirra.CurrencyController do with {:get_user, {:ok, user}} <- {:get_user, Users.get_user(user_id)}, {:curse_user, ^game_id} <- {:curse_user, user.game_id}, - {:add_currency, {:ok, user_currency}} <- - {:add_currency, Currencies.add_currency_by_name_and_game(user_id, currencty_name, user.game_id, amount)} do - send_resp(conn, 200, Jason.encode!(Map.take(user_currency, [:amount, :user_id, :currency_id]))) + {:get_currency, {:ok, currency}} <- + {:get_currency, Currencies.get_currency_by_name_and_game(currencty_name, user.game_id)}, + {:add_currency, {:ok, _}} <- + Ledger.register_currency_earned( + user_id, + [%{currency_id: currency.id, amount: amount}], + "Modified User Currency" + ) do + send_resp(conn, 200, Jason.encode!(%{amount: amount, user_id: user_id, currency_id: currency.id})) else {:get_user, _} -> send_resp(conn, 404, "User not found") {:curse_user, _} -> send_resp(conn, 400, "User from another game")