Wednesday, July 15, 2026 4:00:19 AM
> gamble_spec.rb
# frozen_string_literal: true

describe ESM::Command::Server::Gamble, category: "command" do
  include_context "command"
  include_examples "validate_command"

  describe "V1" do
    describe "#execute" do
      include_context "connection_v1"

      context "when the amount is a number" do
        it "gambles with the amount of poptabs on the server" do
          request = execute!(channel_type: :dm, arguments: {server_id: server.server_id, amount: "300"})

          expect(request).not_to be_nil
          wait_for { connection.requests }.to be_blank
          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed).not_to be(nil)
          expect(embed.title).not_to be_blank
          expect(embed.description).not_to be_blank
        end
      end

      context "when the amount is 'half'" do
        it "gambles half of the user's poptabs on the server" do
          request = execute!(channel_type: :dm, arguments: {server_id: server.server_id, amount: "half"})

          expect(request).not_to be_nil
          wait_for { connection.requests }.to be_blank
          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed).not_to be(nil)
          expect(embed.title).not_to be_blank
          expect(embed.description).not_to be_blank
        end
      end

      context "when the amount is 'all'" do
        it "gambles all of the players money" do
          request = execute!(channel_type: :dm, arguments: {server_id: server.server_id, amount: "all"})
          expect(request).not_to be_nil
          wait_for { connection.requests }.to be_blank
          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed).not_to be(nil)
          expect(embed.title).not_to be_blank
          expect(embed.description).not_to be_blank
        end
      end

      context "when the user does not have enough poptabs" do
        it "returns an error from the server" do
          wsc.flags.NOT_ENOUGH_MONEY = true

          request = execute!(channel_type: :dm, arguments: {server_id: server.server_id, amount: "100000000"})
          expect(request).not_to be_nil
          wait_for { connection.requests }.to be_blank
          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed.description).to match(/not enough poptabs/i)
        end
      end

      context "when the amount is 0" do
        it "raises an exception" do
          execution_args = {channel_type: :dm, arguments: {server_id: server.server_id, amount: "0"}}

          expect { execute!(**execution_args) }.to raise_error(
            ESM::Exception::CheckFailure,
            /you simply cannot gamble nothing/
          )
        end
      end

      context "when the amount is negative" do
        it "raises an exception" do
          execution_args = {channel_type: :dm, arguments: {server_id: server.server_id, amount: "-1"}}

          expect { execute!(**execution_args) }.to raise_error(ESM::Exception::CheckFailure)
        end
      end

      context "when the amount is 'stats'" do
        it "returns the gambling stats for the server" do
          execute!(channel_type: :dm, arguments: {server_id: server.server_id, amount: "stats"})

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed).not_to be(nil)
          expect(embed.title).to match("Gambling statistics")
          expect(embed.fields.size).to eq(14)
        end
      end

      context "when the amount is omitted" do
        it "returns the gambling stats for the server" do
          execute!(channel_type: :dm, arguments: {server_id: server.server_id})

          embed = ESM.discord_bot.test_outbox.first.content
          expect(embed).not_to be(nil)
          expect(embed.title).to match("Gambling statistics")
          expect(embed.fields.size).to eq(14)
        end
      end
    end
  end

  describe "V2", v2: true do
    it "is a player command" do
      expect(command.type).to eq(:player)
    end

    describe "#on_execute", requires_connection: true do
      include_context "connection"

      let(:locker_balance) { 5_000 }  # yea
      let(:amount) {}

      let(:server_setting_default) do
        {gambling_locker_limit_enabled: false}
      end

      let(:server_setting) { {} }

      subject(:execute_command) do
        execute!(arguments: {server_id: server.server_id, amount:})
      end

      ##########################################################################
      # Callbacks

      before do
        server.server_setting.update!(server_setting_default.merge(server_setting))
        user.exile_account.update!(locker: locker_balance)
      end

      ##########################################################################
      # Examples

      shared_examples "reply_with_stats" do
        it "is expected to reply back with the stats" do
          execute_command

          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.retrieve(
            "Gambling statistics for `#{server.server_id}`"
          )&.content

          expect(embed).not_to be(nil)

          expect(embed.fields.size).to eq(14)
        end
      end

      shared_examples "raise_bad_amount" do
        include_examples "raises_check_failure" do
          let(:matcher) { "I'm sorry #{user.mention}, but you simply cannot gamble nothing." }
        end
      end

      shared_examples "successful_gamble_won" do |it_message = nil|
        let!(:server_setting) do
          {
            gambling_win_percentage: 100,
            gambling_payout_base: 100,
            gambling_modifier: 1,
            gambling_payout_randomizer_min: 1,
            gambling_payout_randomizer_mid: 1,
            gambling_payout_randomizer_max: 1
          }
        end

        let(:streak) { 1 }
        let(:won_amount) { amount * 3 } # With everything set to 1
        let(:won_amount_delimited) { won_amount.to_delimitated_s }

        it(it_message || "is expected to gamble the amount and won") do
          execute_command

          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.retrieve("Winner winner!")&.content

          expect(embed).not_to be(nil)
          expect(embed.description).to match("#{won_amount_delimited} poptabs")
          expect(embed.footer.text).to eq("Current Streak: #{streak}")

          new_locker_balance = locker_balance + won_amount
          if defined?(net_id)
            # The in-game locker is set via a networked setVariable, which settles a
            # frame or two after the command response returns. Poll until it lands.
            wait_for {
              execute_sqf! <<~SQF
                private _playerObject = objectFromNetID "#{net_id}";
                if (isNull _playerObject) exitWith { nil };

                _playerObject getVariable ["ExileLocker", -1]
              SQF
            }.to eq(new_locker_balance)
          end

          user.exile_account.reload
          expect(user.exile_account.reload.locker).to eq(new_locker_balance)
        end
      end

      shared_examples "successful_gamble_loss" do |it_message = nil|
        let!(:server_setting) { {gambling_win_percentage: 0} }

        let(:streak) { 1 }
        let(:loss_amount) { amount }
        let(:loss_amount_delimited) { loss_amount.to_delimitated_s }

        it(it_message || "is expected to gamble the amount and lost") do
          execute_command

          ESM.discord_bot.test_outbox.await_size(1)

          embed = ESM.discord_bot.test_outbox.retrieve("Better luck next time!")&.content

          expect(embed).not_to be(nil)
          expect(embed.description).to match("#{loss_amount_delimited} poptabs")
          expect(embed.footer.text).to eq("Current Streak: #{streak}")

          new_locker_balance = locker_balance - loss_amount
          if defined?(net_id)
            # The in-game locker is set via a networked setVariable, which settles a
            # frame or two after the command response returns. Poll until it lands.
            wait_for {
              execute_sqf! <<~SQF
                private _playerObject = objectFromNetID "#{net_id}";
                if (isNull _playerObject) exitWith { nil };

                _playerObject getVariable ["ExileLocker", -1]
              SQF
            }.to eq(new_locker_balance)
          end

          user.exile_account.reload
          expect(user.exile_account.reload.locker).to eq(new_locker_balance)
        end
      end

      ##########################################################################
      # Tests

      context "when the amount is omitted", requires_connection: false do
        let!(:amount) { nil }

        include_examples "reply_with_stats"
      end

      ###

      context "when the amount is 'stats'", requires_connection: false do
        let!(:amount) { "stats" }

        include_examples "reply_with_stats"
      end

      ###

      context "when the server is not connected", requires_connection: false do
        context "and the stats are requested" do
          let!(:amount) { "stats" }

          include_examples "reply_with_stats"
        end

        context "and an amount is given" do
          let!(:amount) { 100 }

          include_examples "raises_server_not_connected"
        end
      end

      ###

      context "when the amount is negative" do
        let(:amount) { -1 }

        include_examples "raise_bad_amount"
      end

      ###

      context "when the amount is zero" do
        let!(:amount) { 0 }

        include_examples "raise_bad_amount"
      end

      ###

      context "when the amount is a positive number" do
        let!(:amount) { 50 }

        context "and the player is online and they won" do
          let!(:net_id) { spawn_player_for(user) }

          include_examples "successful_gamble_won"
        end

        ##

        context "and the player is online and they lost" do
          let!(:net_id) { spawn_player_for(user) }

          include_examples "successful_gamble_loss"
        end

        ##

        context "and the player is offline and they won" do
          include_examples "successful_gamble_won"
        end

        ##

        context "and the player is offline and they lost" do
          include_examples "successful_gamble_loss"
        end

        ##

        context "and the player has a winning streak" do
          include_examples "successful_gamble_won" do
            let(:streak) { 2 }

            before do
              user.user_gamble_stats.first_or_initialize.update!(
                server:,
                current_streak: 1,
                last_action: described_class::WON_ACTION
              )
            end
          end
        end

        ##

        context "and the player has a losing streak" do
          include_examples "successful_gamble_loss" do
            let(:streak) { 2 }

            before do
              user.user_gamble_stats.first_or_initialize.update!(
                server:,
                current_streak: 1,
                last_action: described_class::LOSS_ACTION
              )
            end
          end
        end
      end

      ###

      context "when the amount is 'all'" do
        let!(:amount) { "all" }

        include_examples(
          "successful_gamble_loss",
          "is expected to gamble all of the poptabs and lose"
        ) do
          let!(:loss_amount) { locker_balance }
        end

        context "and the player has no money" do
          # We don't store locker data so the extension has to handle this check
          let!(:locker_balance) { 0 }

          include_examples(
            "raises_extension_error",
            "is expected to raise Gamble_CannotGambleNothing"
          ) do
            let!(:matcher) { "#{user.mention}, you cannot gamble nothing!" }
          end
        end
      end

      ###

      context "when the amount is 'half'" do
        let!(:amount) { "half" }

        include_examples(
          "successful_gamble_loss",
          "is expected to gamble half of the poptabs and lose"
        ) do
          let!(:loss_amount) { locker_balance / 2 }
        end

        context "and the player has no money" do
          # We don't store locker data so the extension has to handle this check
          let!(:locker_balance) { 0 }

          include_examples(
            "raises_extension_error",
            "is expected to raise Gamble_CannotGambleNothing"
          ) do
            let!(:matcher) { "#{user.mention}, you cannot gamble nothing!" }
          end
        end
      end

      ###

      context "when logging is enabled" do
        let!(:amount) { 1 }

        # Loss
        let!(:server_setting) { {gambling_win_percentage: 0, logging_gamble: true} }

        include_examples "arma_discord_logging_enabled" do
          let(:message) { "`ESMs_command_gamble` executed successfully" }

          let(:fields) do
            [
              player_field,
              {
                name: "Locker before",
                value: locker_balance.to_delimitated_s,
                inline: true
              },
              {
                name: "Locker after",
                value: (locker_balance - amount).to_delimitated_s,
                inline: true
              }
            ]
          end
        end
      end

      ###

      context "when logging is disabled" do
        let!(:amount) { 1 }

        # Loss
        let!(:server_setting) { {gambling_win_percentage: 0, logging_gamble: false} }

        include_examples "arma_discord_logging_disabled" do
          let(:message) { "`ESMs_command_gamble` executed successfully" }
        end
      end

      ###

      context "when the player is gambling more than what they have" do
        let!(:amount) { 1_000 }
        let!(:locker_balance) { 0 }

        include_examples "arma_error_too_poor"
      end

      ###

      context "when the locker limit is enabled" do
        let!(:amount) { 1_000 }

        # Win
        let!(:server_setting) do
          {gambling_locker_limit_enabled: true, gambling_win_percentage: 100}
        end

        let!(:max_deposit) do
          execute_sqf!("getNumber(missionConfigFile >> 'CfgLocker' >> 'maxDeposit')")
        end

        context "and the player has too many poptabs in their locker before gambling" do
          let!(:locker_balance) { max_deposit }

          include_examples "raises_extension_error", "is expected to raise Gamble_LockerFull" do
            let!(:matcher) { "#{user.mention}, your locker is full" }
          end
        end

        ##

        context "and the player has too many poptabs in their locker after gambling" do
          let!(:locker_balance) { max_deposit - amount }

          it "is expected to succeed but caps the locker amount to maxDeposit" do
            execute_command

            ESM.discord_bot.test_outbox.await_size(1)

            embed = ESM.discord_bot.test_outbox.retrieve("Winner winner!")&.content

            expect(embed).not_to be(nil)
            expect(embed.description).to match("poptabs due to server limits")
            expect(user.exile_account.reload.locker).to eq(max_deposit)
          end
        end
      end
    end
  end
end
All opinions represented herein are my own
- © 2024 - 2026 itsthedevman
- build 43aa0d7