diff --git a/Bot.py b/Bot.py index aa97467..d5454cc 100644 --- a/Bot.py +++ b/Bot.py @@ -327,29 +327,121 @@ async def on_thread_update( async def cmd_flip(ctx: misc.BotContext, bet: str = "10") -> int: if misc.ctx_created_thread(ctx): return -1 - report = ( - bucks.FinMsg.format(ctx.author.mention) - if bucks.active_game(BlackjackGames, ctx.author) - else bucks.flip(ctx.author, bet.lower()) - ) + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + else: + report = ( + bucks.FinMsg.format(ctx.author.mention) + if bucks.player_in_game(BlackjackGames, ctx.author) + else bucks.flip(ctx.author, bet.lower()) + ) await ctx.send(embed=misc.bb_embed("Beardless Bot Coin Flip", report)) return 1 +# NOTE: duplicate code @BeardlessBot.command(name="blackjack", aliases=("bj",)) async def cmd_blackjack(ctx: misc.BotContext, bet: str = "10") -> int: if misc.ctx_created_thread(ctx): return -1 - if bucks.active_game(BlackjackGames, ctx.author): + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + elif bucks.player_in_game(BlackjackGames, ctx.author): + report = bucks.FinMsg.format(ctx.author.mention) + else: + can_bet, bet_report = bucks.can_make_bet(ctx.author, bet) + if not can_bet: + assert bet_report is not None + report = bet_report + else: + report, game = bucks.blackjack(ctx.author, bet) + if game and not game.round_over(): + BlackjackGames.append(game) + await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) + return 1 + + +@BeardlessBot.command(name="tableleave") +async def cmd_tableleave(ctx: misc.BotContext) -> int: + if misc.ctx_created_thread(ctx): + return -1 + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if not game.multiplayer: + report = ( + "You can't exit a singlelpayer Blackjack Game" + f"{ctx.author.mention}\n" + ) + elif game.started: + report = "Cannot leave mid-round. Please wait for the round to end." + elif len(game.players) == 1: + BlackjackGames.remove(game) + report = "Game disbanded.\n" + elif player == game.owner: + assert game.owner == game.players[0] + game.players.remove(player) + game.owner = game.players[0] + report = ( + f"You left. {game.owner.name.mention} " + "you are now the owner of the game.\n" + ) + else: + game.players.remove(player) + report = "You left.\n" + else: + report = bucks.NoMultiplayerGameMsg.format(ctx.author.mention) + await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) + return 1 + + +# NOTE: duplicate code +@BeardlessBot.command(name="tablenew") +async def cmd_tablenew(ctx: misc.BotContext) -> int: + if misc.ctx_created_thread(ctx): + return -1 + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + if bucks.player_in_game(BlackjackGames, ctx.author): report = bucks.FinMsg.format(ctx.author.mention) else: - report, game = bucks.blackjack(ctx.author, bet) + report, game = bucks.blackjack(ctx.author, None) if game: BlackjackGames.append(game) await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) return 1 +@BeardlessBot.command(name="tablebet") +async def cmd_tablebet(ctx: misc.BotContext, bet: str = "10") -> int: + if misc.ctx_created_thread(ctx): + return -1 + report: str | None + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + else: + report = bucks.NoMultiplayerGameMsg.format(ctx.author.mention) + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if game.multiplayer: + if game.started: + report = "Cannot change bet mid-round." + else: + can_bet, report = bucks.can_make_bet(ctx.author, bet) + if can_bet: + report, bet_number = bucks.make_bet( + ctx.author, + game, bet, + ) + report = ( + "Your current bet is " + f"{bet_number}\n{ctx.author.mention}" + ) + player.bet = bet_number + assert report is not None + await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) + return 1 + + @BeardlessBot.command(name="deal", aliases=("hit",)) async def cmd_deal(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): @@ -358,18 +450,85 @@ async def cmd_deal(ctx: misc.BotContext) -> int: report = bucks.CommaWarn.format(ctx.author.mention) else: report = bucks.NoGameMsg.format(ctx.author.mention) - if game := bucks.active_game(BlackjackGames, ctx.author): - report = game.deal_to_player() - if game.check_bust() or game.perfect(): - game.check_bust() - bucks.write_money( - ctx.author, game.bet, writing=True, adding=True, - ) - BlackjackGames.remove(game) + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if not game.started: + report = "Game has not started yet" + elif not game.is_turn(player): + report = f"It is not your turn {ctx.author.mention}" + else: + assert game.dealerUp is not None + report = game.deal_current_player() + if ( + (player.check_bust() or player.perfect()) + and not game.multiplayer + ): + BlackjackGames.remove(game) + await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) + return 1 + + +@BeardlessBot.command(name="tablestart") +async def cmd_tablestart(ctx: misc.BotContext) -> int: + if misc.ctx_created_thread(ctx): + return -1 + report = bucks.NoGameMsg.format(ctx.author.mention) + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if game.owner is not player: + report = "You are not the owner of this table" + elif not game.ready_to_start(): + report = "Not all players have made their bets" + else: + report = "Match started\n" + report += game.start_game() await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) return 1 +@BeardlessBot.command(name="tablejoin") +async def cmd_tablejoin( + ctx: misc.BotContext, + target: str | None = None, +) -> int: + if misc.ctx_created_thread(ctx) or not ctx.guild: + return -1 + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + else: + if not (join_target := await misc.process_command_target( + ctx, target, BeardlessBot, + )): + return 0 + if result := bucks.player_in_game(BlackjackGames, ctx.author): + report = bucks.FinMsg.format(ctx.author.mention) + elif result := bucks.player_in_game(BlackjackGames, join_target): + game, _ = result + if game.multiplayer: + if game.started: + game.add_player(ctx.author) + report = f"Joined {join_target.mention}'s blackjack game." + else: + report = ( + f"Cannot join {join_target.mention}'s " + "blackjack game mid-round. " + "Please wait for the round to end." + ) + else: + report = ( + f"Can't join {join_target.mention}'s " + "singleplayer blackjack game." + ) + else: + report = f"Player {join_target.mention} is not in a blackjack game" + await ctx.send(embed=misc.bb_embed("Beardless Bot Join", report)) + # if channel := misc.get_log_channel(ctx.guild): + # await channel.send(embed=logs.log_mute( + # join_target, ctx.message, duration, + # )) + return 1 + + @BeardlessBot.command(name="stay", aliases=("stand",)) async def cmd_stay(ctx: misc.BotContext) -> int: if misc.ctx_created_thread(ctx): @@ -378,17 +537,16 @@ async def cmd_stay(ctx: misc.BotContext) -> int: report = bucks.CommaWarn.format(ctx.author.mention) else: report = bucks.NoGameMsg.format(ctx.author.mention) - if game := bucks.active_game(BlackjackGames, ctx.author): - result = game.stay() - report = game.message - if result and game.bet: - written, bonus = bucks.write_money( - ctx.author, game.bet, writing=True, adding=True, - ) - if written == bucks.MoneyFlags.CommaInUsername: - assert isinstance(bonus, str) - report = bonus - BlackjackGames.remove(game) + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if not game.started: + report = "Game has not started yet" + elif not game.is_turn(player): + report = f"It is not your turn {ctx.author.mention}" + else: + report = game.stay_current_player() + if not game.multiplayer: + BlackjackGames.remove(game) await ctx.send(embed=misc.bb_embed("Beardless Bot Blackjack", report)) return 1 @@ -434,9 +592,14 @@ async def cmd_balance(ctx: misc.BotContext, *, target: str = "") -> int: async def cmd_leaderboard(ctx: misc.BotContext, *, target: str = "") -> int: if misc.ctx_created_thread(ctx): return -1 - await ctx.send( - embed=bucks.leaderboard(misc.get_target(ctx, target), ctx.message), - ) + if "," in ctx.author.name: + embed = misc.bb_embed( + "BeardlessBot Comma Warn", + bucks.CommaWarn.format(ctx.author.mention), + ) + else: + embed = bucks.leaderboard(misc.get_target(ctx, target), ctx.message) + await ctx.send(embed=embed) return 1 @@ -469,7 +632,21 @@ async def cmd_reset(ctx: misc.BotContext) -> int: """ if misc.ctx_created_thread(ctx): return -1 - await ctx.send(embed=bucks.reset(ctx.author)) + if "," in ctx.author.name: + report = bucks.CommaWarn.format(ctx.author.mention) + else: + game = None + if result := bucks.player_in_game(BlackjackGames, ctx.author): + game, player = result + if game is None: + report = bucks.reset(ctx.author) + elif game.multiplayer and not game.started: + player.bet = 10 # TODO: move the default bet to a variable + report = bucks.reset(ctx.author) + report += " Your bet has also been reset to 10." + else: + report = bucks.FinMsg.format(ctx.author.mention) + await ctx.send(embed=misc.bb_embed("BeardlessBucks Reset", report)) return 1 @@ -845,7 +1022,7 @@ async def cmd_buy( else: if not role.color.value: await role.edit(colour=nextcord.Colour(RoleColors[color])) - result, bonus = bucks.write_money( + result, _ = bucks.write_money( ctx.author, -50000, writing=True, adding=True, ) if result == bucks.MoneyFlags.BalanceChanged: @@ -853,9 +1030,6 @@ async def cmd_buy( "Color " + role.mention + " purchased successfully, {}!" ) await ctx.author.add_roles(role) - elif result == bucks.MoneyFlags.CommaInUsername: - assert isinstance(bonus, str) - report = bonus elif result == bucks.MoneyFlags.Registered: report = bucks.NewUserMsg else: diff --git a/bb_test.py b/bb_test.py index c64495c..35a55ae 100644 --- a/bb_test.py +++ b/bb_test.py @@ -2379,11 +2379,6 @@ async def test_cmd_register() -> None: assert m is not None assert m.embeds[0].description == emb.description - bb._user.name = ",badname," - assert bucks.register(bb).description == ( - bucks.CommaWarn.format(f"<@{misc.BbId}>") - ) - @MarkAsync @pytest.mark.parametrize( @@ -2393,7 +2388,6 @@ async def test_cmd_register() -> None: MockMember(MockUser("Test", "5757", misc.BbId)), "'s balance is 200", ), - (MockMember(MockUser(",")), bucks.CommaWarn.format("<@123456789>")), ], ) async def test_cmd_balance(target: nextcord.User, result: str) -> None: @@ -2420,15 +2414,10 @@ def test_reset() -> None: MockUser("Beardless Bot", discriminator="5757", user_id=misc.BbId), "Beardless Bot", ) - assert bucks.reset(bb).description == ( + assert bucks.reset(bb) == ( f"You have been reset to 200 BeardlessBucks, <@{misc.BbId}>." ) - bb._user.name = ",badname," - assert bucks.reset(bb).description == ( - bucks.CommaWarn.format(f"<@{misc.BbId}>") - ) - def test_write_money() -> None: bb = MockMember( @@ -2442,7 +2431,7 @@ def test_write_money() -> None: assert bucks.write_money( bb, -1000000, writing=True, adding=False, - ) == (bucks.MoneyFlags.NotEnoughBucks, None) + ) == (bucks.MoneyFlags.NotEnoughBucks, 200) def test_leaderboard() -> None: @@ -2453,12 +2442,6 @@ def test_leaderboard() -> None: assert lb.fields[1].value is not None assert int(lb.fields[0].value) > int(lb.fields[1].value) - lb = bucks.leaderboard( - MockMember(MockUser(name="bad,name", user_id=0)), - MockMessage(author=MockMember()), - ) - assert len(lb.fields) == 10 - lb = bucks.leaderboard( MockMember( MockUser("Beardless Bot", discriminator="5757", user_id=misc.BbId), @@ -2593,7 +2576,7 @@ def test_flip() -> None: mp.setattr("random.randint", lambda *_: 0) assert bucks.flip(bb, "all") == ( "Tails! You lose! Your losses have been" - f" deducted from your balance, <@{misc.BbId}>." + f" deducted from your balance, <@{misc.BbId}>.\n" ) msg = bucks.balance(bb, MockMessage("!bal", bb)) assert isinstance(msg.description, str) @@ -2612,7 +2595,7 @@ def test_flip() -> None: mp.setattr("random.randint", lambda *_: 1) assert bucks.flip(bb, "all") == ( "Heads! You win! Your winnings have been" - f" added to your balance, <@{misc.BbId}>." + f" added to your balance, <@{misc.BbId}>.\n" ) msg = bucks.balance(bb, MockMessage("!bal", bb)) assert isinstance(msg.description, str) @@ -2634,9 +2617,6 @@ def test_flip() -> None: bucks.NewUserMsg.format(f"<@{misc.BbId}>") ) - bb._user.name = ",invalidname," - assert bucks.flip(bb, "0") == bucks.CommaWarn.format(f"<@{misc.BbId}>") - @MarkAsync async def test_cmd_flip() -> None: @@ -2655,7 +2635,7 @@ async def test_cmd_flip() -> None: assert emb.description is not None assert emb.description.endswith("actually bet anything.") - Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) + Bot.BlackjackGames.append(bucks.BlackjackGame(bb, multiplayer=False)) assert await Bot.cmd_flip(ctx, bet="0") == 1 m = await latest_message(ctx) assert m is not None @@ -2671,14 +2651,15 @@ def test_blackjack() -> None: bucks.reset(bb) with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: False) + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: False) report, game = bucks.blackjack(bb, 0) assert isinstance(game, bucks.BlackjackGame) - assert "You hit 21!" not in report + assert "you hit 21!" not in report with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) - report, game = bucks.blackjack(bb, "0") + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: True) + mp.setattr("random.randint", lambda x, _: x) # no dealer blackjack + report, game = bucks.blackjack(bb, 0) assert game is None assert "You hit 21" in report @@ -2687,20 +2668,14 @@ def test_blackjack() -> None: "bucks.write_money", lambda *_, **__: (bucks.MoneyFlags.Registered, 0), ) - assert bucks.blackjack(bb, "0")[0] == ( + assert ( bucks.NewUserMsg.format(f"<@{misc.BbId}>") - ) + in bucks.blackjack(bb, "0")[0]) bucks.reset(bb) report = bucks.blackjack(bb, "10000000000000")[0] assert report.startswith("You do not have") - bucks.reset(bb) - bb._user.name = ",invalidname," - assert bucks.blackjack(bb, 0)[0] == ( - bucks.CommaWarn.format(f"<@{misc.BbId}>") - ) - @MarkAsync async def test_cmd_blackjack() -> None: @@ -2714,20 +2689,20 @@ async def test_cmd_blackjack() -> None: m = await latest_message(ctx) assert m is not None assert m.embeds[0].description is not None - assert m.embeds[0].description.startswith("Your starting hand consists of") + assert "your starting hand consists of" in m.embeds[0].description with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: True) Bot.BlackjackGames = [] assert await Bot.cmd_blackjack(ctx, bet="all") == 1 m = await latest_message(ctx) assert m is not None assert m.embeds[0].description is not None assert m.embeds[0].description.endswith( - f"You hit {bucks.BlackjackGame.Goal}! You win, {bb.mention}!", + f"You hit {bucks.BlackjackGame.Goal}! {bucks.WinMsg}.\n", ) - Bot.BlackjackGames.append(bucks.BlackjackGame(bb, 10)) + Bot.BlackjackGames.append(bucks.BlackjackGame(bb, multiplayer=False)) assert await Bot.cmd_blackjack(ctx, bet="0") == 1 m = await latest_message(ctx) assert m is not None @@ -2736,7 +2711,7 @@ async def test_cmd_blackjack() -> None: @MarkAsync -async def test_cmd_deal() -> None: +async def test_cmd_deal1() -> None: Bot.BlackjackGames = [] bb = MockMember( MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), @@ -2753,20 +2728,25 @@ async def test_cmd_deal() -> None: assert m is not None assert m.embeds[0].description == bucks.NoGameMsg.format(f"<@{misc.BbId}>") - game = bucks.BlackjackGame(bb, 0) - game.hand = [2, 2] - Bot.BlackjackGames = [] - Bot.BlackjackGames.append(game) + game = bucks.BlackjackGame(bb, multiplayer=False) + assert len(game.players) == 1 + player = game.players[0] + assert game.turn_idx == 0 + player.hand = [2, 2] + Bot.BlackjackGames = [game] assert await Bot.cmd_deal(ctx) == 1 m = await latest_message(ctx) assert m is not None emb = m.embeds[0] - assert len(game.hand) == 3 assert emb.description is not None - assert emb.description.startswith("You were dealt") - - game = bucks.BlackjackGame(bb, 0) - game.hand = [10, 10, 10] + assert emb.description.startswith(f"{bb.mention} you were dealt") + assert len(player.hand) == 3 + + game = bucks.BlackjackGame(bb, multiplayer=False) + assert len(game.players) == 1 + player = game.players[0] + player.bet = 0 + player.hand = [10, 10, 10] Bot.BlackjackGames = [] Bot.BlackjackGames.append(game) assert await Bot.cmd_deal(ctx) == 1 @@ -2774,69 +2754,117 @@ async def test_cmd_deal() -> None: assert m is not None emb = m.embeds[0] assert emb.description is not None - assert f"You busted. Game over, <@{misc.BbId}>." in emb.description + assert "You busted. Game over" in emb.description + assert f"<@{misc.BbId}>" in emb.description assert len(Bot.BlackjackGames) == 0 - game = bucks.BlackjackGame(bb, 0) - game.hand = [10, 10] + +@MarkAsync +async def test_cmd_deal2() -> None: + # your boy ruff doesn't like more than 50 stmts in functions + Bot.BlackjackGames = [] + bb = MockMember( + MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), + ) + ch = MockChannel(guild=MockGuild()) + ctx = MockContext( + Bot.BeardlessBot, MockMessage("!hit"), ch, bb, MockGuild(), + ) + Bot.BlackjackGames = [] + bb = MockMember( + MockUser("Beardless,Bot", discriminator="5757", user_id=misc.BbId), + ) + ch = MockChannel(guild=MockGuild()) + ctx = MockContext( + Bot.BeardlessBot, MockMessage("!hit"), ch, bb, MockGuild(), + ) + assert await Bot.cmd_deal(ctx) == 1 + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.CommaWarn.format(f"<@{misc.BbId}>") + + bb._user.name = "Beardless Bot" + assert await Bot.cmd_deal(ctx) == 1 + m = await latest_message(ch) + assert m is not None + assert m.embeds[0].description == bucks.NoGameMsg.format(f"<@{misc.BbId}>") + + game = bucks.BlackjackGame(bb, multiplayer=False) + player = game.players[0] + player.bet = 0 + player.hand = [10, 10] Bot.BlackjackGames.append(game) with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) - mp.setattr("bucks.BlackjackGame.check_bust", lambda _: False) + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: True) + mp.setattr("bucks.BlackjackPlayer.check_bust", lambda _: False) assert await Bot.cmd_deal(ctx) == 1 m = await latest_message(ctx) assert m is not None emb = m.embeds[0] assert emb.description is not None - assert f"You hit 21! You win, <@{misc.BbId}>!" in emb.description + assert "You hit 21! You win" in emb.description + assert f"<@{misc.BbId}>" in emb.description assert len(Bot.BlackjackGames) == 0 def test_blackjack_deal_to_player_treats_ace_as_1_when_going_over() -> None: - game = bucks.BlackjackGame(MockMember(), 10) - game.hand = [8, 11] + m = MockMember() + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 10 + player.hand = [11, 9] with pytest.MonkeyPatch.context() as mp: - game.deck = [3, 4, 5] + game.deck = [2, 4, 5] mp.setattr("random.randint", lambda x, _: x) - game.deal_to_player() - assert len(game.hand) == 3 - assert sum(game.hand) == 12 - assert game.message.startswith( - "You were dealt a 3, bringing your total to 22. To avoid busting," + assert game.dealerUp is not None + report = game.deal_current_player() + assert len(player.hand) == 3 + assert sum(player.hand) == 12 + assert report.startswith( + f"{player.name.mention} you were dealt a 2, " + "bringing your total to 22. To avoid busting," " your Ace will be treated as a 1. Your new total is 12.", ) def test_blackjack_deal_to_player_wins_when_reaching_21() -> None: m = MockMember() - game = bucks.BlackjackGame(m, 10) - game.hand = [10, 9] + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 10 + player.hand = [10, 9] + assert game.dealerUp is not None with pytest.MonkeyPatch.context() as mp: game.deck = [2, 3, 4] mp.setattr("random.randint", lambda x, _: x) - game.deal_to_player() - assert game.message.startswith( - "You were dealt a 2, bringing your total to 21." + report = game.deal_current_player() + assert report.startswith( + f"{m.mention} you were dealt a 2, bringing your total to 21." " Your card values are 10, 9, 2. The dealer is showing ", ) - assert game.message.endswith( - f", with one card face down. You hit 21! You win, {m.mention}!", + assert report.endswith( + ", with one card face down. You hit 21! " + f"{bucks.WinMsg}, {m.mention}.\n", ) def test_blackjack_deal_top_card_pops_top_card() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("random.randint", lambda x, _: x) - game = bucks.BlackjackGame(MockMember(), 10) + m = MockMember() + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 10 # Two cards dealt to player, two to dealer # Dealer dealt 2, 3; player dealt 4, 5 - assert len(game.deck) == 48 + starting_deck_count = 13 * 4 * bucks.BlackjackGame.NumOfDecksInMatch + assert len(game.deck) == starting_deck_count - 4 assert game.dealerUp == 2 assert game.dealerSum == 5 - assert sum(game.hand) == 9 + assert sum(player.hand) == 9 # Next card should be a 6 assert game.deal_top_card() == 6 - assert len(game.deck) == 47 + assert len(game.deck) == starting_deck_count - 5 def test_blackjack_card_name() -> None: @@ -2849,12 +2877,15 @@ def test_blackjack_card_name() -> None: def test_blackjack_check_bust() -> None: - game = bucks.BlackjackGame(MockMember(), 10) - game.hand = [10, 10, 10] - assert game.check_bust() + m = MockMember() + player = bucks.BlackjackPlayer(m) + bucks.BlackjackGame(m, multiplayer=False) + player.hand = [10, 10, 10] + player.bet = 10 + assert player.check_bust() - game.hand = [3, 4] - assert not game.check_bust() + player.hand = [3, 4] + assert not player.check_bust() @MarkAsync @@ -2874,72 +2905,100 @@ async def test_cmd_stay() -> None: def test_blackjack_stay() -> None: with pytest.MonkeyPatch.context() as mp: mp.setattr("random.randint", lambda x, _: x) - member = MockMember() - game = bucks.BlackjackGame(member, 0) - game.hand = [10, 10, 1] - game.dealerSum = 25 - assert game.stay() == 1 - assert game.message.endswith( - f"to your balance, {member.mention}." - " Unfortunately, you bet nothing, so this was all pointless.", - ) - - game.bet = 10 + m = MockMember() + + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 0 + player.hand = [10, 1] + game.dealerSum = 13 + game.dealerUp = 6 + game.deck = [8] + game.stay_current_player() + assert game.round_over() + assert "you lose" in game._end_round().lower() + + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 0 + player.hand = [10, 1] + game.dealerSum = 12 + game.dealerUp = 5 + game.deck = [10, 10] + game.stay_current_player() + assert game.round_over() + assert "you win" in game._end_round().lower() + + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 0 + player.hand = [10, 10] game.dealerSum = 20 - assert game.stay() == 1 - assert game.message.endswith(f"to your balance, {member.mention}.") - game.deal_to_player() - assert game.stay() == 1 - assert game.message.endswith(f"from your balance, {member.mention}.") - - game.hand = [10, 10] - assert game.stay() == 0 - - game.dealerSum = 14 - assert game.stay() == 1 + game.dealerUp = 10 + game.stay_current_player() + assert game.round_over() + assert "ties your sum" in game._end_round().lower() def test_blackjack_starting_hand() -> None: m = MockMember() - game = bucks.BlackjackGame(m, 10) - game.hand = [] - game.message = game.starting_hand() - assert len(game.hand) == 2 - assert game.message.startswith("Your starting hand consists of ") + game = bucks.BlackjackGame(m, multiplayer=False) + player = game.players[0] + player.bet = 10 + player.hand = [] + game.message = game.start_game() + assert len(player.hand) == 2 + assert f"{m.mention} your starting hand consists of " in game.message + + player.hand = [] + with pytest.MonkeyPatch.context() as mp: + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: False) + assert "You hit 21!" not in game.start_game() + assert len(player.hand) == 2 - game.hand = [] + player.hand = [] with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: False) - assert "You hit 21!" not in game.starting_hand() - assert len(game.hand) == 2 + mp.setattr("bucks.BlackjackPlayer.perfect", lambda _: True) + mp.setattr("random.randint", lambda x, _: x) # for deck draws + game.deck = [ + 2, 3, # no dealer blackjack + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + ] + report = game.start_game() + assert "You hit 21!" in report + assert len(player.hand) == 2 + + player.hand = [] - game.hand = [] with pytest.MonkeyPatch.context() as mp: - mp.setattr("bucks.BlackjackGame.perfect", lambda _: True) - assert "You hit 21!" in game.starting_hand() - assert len(game.hand) == 2 - - game.hand = [] - game.deck = [bucks.BlackjackGame.AceVal, bucks.BlackjackGame.AceVal] - assert game.starting_hand() == ( - "Your starting hand consists of two Aces." - " One of them will act as a 1. Your total is 12." - " Type !hit to deal another card to yourself, or !stay" - f" to stop at your current total, {m.mention}." - ) - assert len(game.hand) == 2 - assert game.hand[1] == 1 + mp.setattr("random.randint", lambda _, y: y) + game.deck = [ + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.AceVal, + 1, 2, + ] + assert game.start_game() == ( + "The dealer is showing 2, with one card face down.\n" + f"{m.mention} your starting hand consists of two Aces." + " One of them will act as a 1. Your total is 12.\n" + "Type !hit to deal another card to yourself, or !stay" + f" to stop at your current total." + ) + assert len(player.hand) == 2 + assert player.hand[1] == 1 def test_active_game() -> None: author = MockMember(MockUser(name="target", user_id=0)) games = [ - bucks.BlackjackGame(MockMember(MockUser(name="not", user_id=1)), 10), + bucks.BlackjackGame( + MockMember(MockUser(name="not", user_id=1)), + multiplayer=False, + ), ] * 9 - assert bucks.active_game(games, author) is None + assert bucks.player_in_game(games, author) is None - games.append(bucks.BlackjackGame(author, 10)) - assert bucks.active_game(games, author) + games.append(bucks.BlackjackGame(author, multiplayer=False)) + assert bucks.player_in_game(games, author) is not None def test_info() -> None: @@ -4213,3 +4272,286 @@ async def test_legend_info() -> None: ) assert await brawl.legend_info(BrawlKey, "invalidname") is None + + +def test_add_player_get_player_and_ready_to_start() -> None: + game = bucks.BlackjackGame(MockMember(), multiplayer=True) + owner = game.players[0] + + assert game.multiplayer is True + assert len(game.players) == 1 + assert game.ready_to_start() is True + assert int(owner.bet) == 10 # please move starting bet into a variable + + # test get_player with players not in game + p2 = MockUser() + assert game.get_player(p2) is None + + # add another player and ensure get_player finds them + game.add_player(p2) + assert len(game.players) == 2 + found = game.get_player(p2) + assert found is not None + assert found.name == p2 + + # this should be tested when + # BlackjackPlayer.bet's type is changed to 'int | None' + # for more info grep for '805746791' + # found.bet = None + # assert game.ready_to_start() is False + # found.bet = 5 + # assert game.ready_to_start() is True + # owner.bet = None + # assert game.ready_to_start() is False + # owner.bet = 5 + # assert game.ready_to_start() is True + + +def test_is_turn_and_advance_turn_skips_perfect_players() -> None: + game = bucks.BlackjackGame(MockMember(), multiplayer=True) + game.add_player(MockMember()) + game.add_player(MockMember()) + + assert game.turn_idx == 0 + assert game.is_turn(game.players[0]) is True + + game.advance_turn() + assert game.turn_idx == 1 + assert game.is_turn(game.players[1]) is True + + assert len(game.players) == 3 + # test skipping of players who blackjacked + game.players[2].hand = [ + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + ] + game.advance_turn() + # make sure the turn_idx is no longer valid. + # all we know is that it should not a valid idx into BlackjackGame.players + assert not (game.turn_idx > 0 and game.turn_idx < len(game.players)) + + +def test_dealer_draw_stops_at_dealer_soft_goal() -> None: + game = bucks.BlackjackGame(MockMember(), multiplayer=True) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, _: x) + + # this is major ass please replace dealerUp & dealerSum with dealerCards + game.dealerUp = 10 + game.dealerSum = bucks.BlackjackGame.DealerSoftGoal - 1 + game.deck = [1, 5, 9, 11] + dealer_cards = game.dealer_draw() + assert dealer_cards == [10, 6, 1] + assert game.dealerSum == 17 + + # test no draw on soft-goal + game.dealerUp = 10 + game.dealerSum = bucks.BlackjackGame.DealerSoftGoal + game.deck = [1, 5, 9, 11] + dealer_cards = game.dealer_draw() + assert dealer_cards == [10, 7] + assert game.dealerSum == bucks.BlackjackGame.DealerSoftGoal + + +def make_blackjack_multiplayer_with_unique_user_id( + num_of_players: int, +) -> bucks.BlackjackGame: + """ + Create a multiplayer blackjack games with unique user-ids. + + Args: + num_of_players (int): 1-9 inclusive. The number of players in the game. + + Returns: + BlackjackGame: the created blackjack game + + """ + assert num_of_players > 0 + assert num_of_players < 10 + game = bucks.BlackjackGame( + MockMember(user=MockUser(user_id=1111)), + multiplayer=True, + ) + for i in range(num_of_players - 1): + d = i + 2 + new_id = d + d * 10 + d * 100 + d * 1000 + game.add_player(MockMember(user=MockUser(user_id=new_id))) + return game + + +def test_blackjack_multiplayer_start_game_skip_perfected_players() -> None: + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, _: x) # for deck draws + + game = make_blackjack_multiplayer_with_unique_user_id(3) + game.deck = [ + 1, 2, # no dealer blackjack + 3, 4, 5, 6, 7, 8, + ] + report = game.start_game() + assert game.is_turn(game.players[0]) + assert report.endswith(f"<@1111> it is your turn! {bucks.GameHelpMsg}") + + game = make_blackjack_multiplayer_with_unique_user_id(3) + game.deck = [ + 1, 2, # no dealer blackjack + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + 3, 4, 5, 6, 7, 8, + ] + report = game.start_game() + assert game.is_turn(game.players[1]) + assert report.endswith(f"<@2222> it is your turn! {bucks.GameHelpMsg}") + + game = make_blackjack_multiplayer_with_unique_user_id(4) + game.deck = [ + 1, 2, # no dealer blackjack + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + 3, 4, 5, 6, + ] + report = game.start_game() + assert game.is_turn(game.players[2]) + assert report.endswith(f"<@3333> it is your turn! {bucks.GameHelpMsg}") + + +def test_blackjack_multiplayer() -> None: + # this is sort of an integration test I guess + game = make_blackjack_multiplayer_with_unique_user_id(5) + p2 = game.players[1] + p3 = game.players[2] + p5 = game.players[4] + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, _: x) # for deck draws + mp.setattr( + "random.choice", + operator.itemgetter(0), + ) # for facecard names + game.deck = [ + 1, 2, # no dealer blackjack + 3, 4, 10, 9, 7, 10, + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.AceVal, + 10, 4, + ] + report = game.start_game() # will not blackjack + # maybe overkill? + assert game.dealerUp == 1 + assert game.dealerSum == 3 + assert not game.round_over() + assert report == """\ +The dealer is showing 1, with one card face down. +<@1111> your starting hand consists of a 3 and a 4. Your total is 7. +<@2222> your starting hand consists of a 10 and a 9. Your total is 19. +<@3333> your starting hand consists of a 7 and a 10. Your total is 17. +<@4444> your starting hand consists of an Ace and a 10. \ +You hit 21! You win! Your winnings have been added to your balance. +<@5555> your starting hand consists of two Aces. \ +One of them will act as a 1. Your total is 12. + +<@1111> it is your turn! Type !hit to deal another card to yourself, \ +or !stay to stop at your current total.\ +""" + game.stay_current_player() + assert game.is_turn(p2) + assert not game.round_over() + game.stay_current_player() + assert game.is_turn(p3) + assert not game.round_over() + game.stay_current_player() + assert game.is_turn(p5) + assert not game.round_over() + report = game.stay_current_player() + assert game.round_over() + for p in game.players: + assert not game.is_turn(p) + assert report == """\ +<@5555> you stayed. +Round ended, the dealer will now play +The dealer's cards are a 1, a 2, a 10, a 4 for a total of 17. +<@1111>, That's closer to 21 than your sum of 7. \ +You lose! Your losses have been deducted from your balance. +<@2222>, you're closer to 21 with a sum of 19. \ +You win! Your winnings have been added to your balance +<@3333>, That ties your sum of 17. Your bet has been returned, <@3333>. +<@5555>, That's closer to 21 than your sum of 12. \ +You lose! Your losses have been deducted from your balance. + +Round ended!\ +""" + + +def test_deal_current_player() -> None: + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, _: x) # for deck draws + mp.setattr( + "random.choice", + operator.itemgetter(0), + ) # for facecard names + game = make_blackjack_multiplayer_with_unique_user_id(3) + game.deck = [ + 1, 2, # no dealer blackjack + 1, 5, + 3, 4, 5, 6, + 7, 10, + ] + game.start_game() + report = game.deal_current_player() + assert game.players[0].hand == [1, 5, 7] + assert report.startswith( + "<@1111> you were dealt a 7, bringing your total to 13", + ) + report = game.deal_current_player() + assert game.players[0].hand == [1, 5, 7, 10] + assert report.startswith( + "<@1111> you were dealt a 10, bringing your total to 23", + ) + assert report.endswith( + "You busted. Game over.\n<@2222>, it is your turn.\n", + ) + assert not game.is_turn(game.players[0]) + assert game.is_turn(game.players[1]) + + game = make_blackjack_multiplayer_with_unique_user_id(2) + game.deck = [ + 1, 2, # no dealer blackjack + bucks.BlackjackGame.AceVal, 5, # ace overflow + 5, 6, 10, + ] + game.start_game() + assert game.players[0].hand == [bucks.BlackjackGame.AceVal, 5] + report = game.deal_current_player() + assert game.players[0].hand == [1, 5, 10] + assert ( + "To avoid busting, your Ace will be treated as a 1. " + "Your new total is 16" + ) in report + assert not game.players[0].check_bust() + assert game.is_turn(game.players[0]) + + +def test_blackjack_multiplayer_dealer_blackjack() -> None: + game = make_blackjack_multiplayer_with_unique_user_id(3) + with pytest.MonkeyPatch.context() as mp: + mp.setattr("random.randint", lambda x, _: x) # for deck draws + mp.setattr( + "random.choice", + operator.itemgetter(0), + ) # for facecard names + game.deck = [ + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + 3, 4, + bucks.BlackjackGame.AceVal, bucks.BlackjackGame.FaceVal, + 7, 10, + ] + report = game.start_game() + assert report == """\ +The dealer blackjacked! +<@1111> your starting hand consists of 3 and 4. \ +You did not blackjack, you lose. +<@2222> your starting hand consists of 11 and 10. \ +You tied with the dealer, your bet is returned. +<@3333> your starting hand consists of 7 and 10. \ +You did not blackjack, you lose. + +Round ended.\ +""" diff --git a/bucks.py b/bucks.py index 0de79e9..54bec9b 100644 --- a/bucks.py +++ b/bucks.py @@ -29,6 +29,82 @@ " going, {}. Type !blackjack to start one." ) +NoMultiplayerGameMsg = ( + "You do not currently have a multiplayer game of blackjack" + " going, {}. Type '!blackjack new' to start one." +) + +InvalidBetMsg = ( + "Invalid bet. Please choose a number greater than or equal" + " to 0, or enter \"all\" to bet your whole balance, {}." +) + +WinMsg = "You win! Your winnings have been added to your balance" +LoseMsg = "You lose! Your losses have been deducted from your balance" + +GameHelpMsg = ( + "Type !hit to deal another card to yourself, " + "or !stay to stop at your current total." +) + + +class BlackjackPlayer: + """ + BlackjackPlayer instantce. + + Attributes: + name (Nextcord.User | Nextcord.Member): + The discord user representing the player + hand (list[int]): The player's current hand + bet (int): The player's current bet + + Methods: + check_bust(): Check if the player has gone over BlackjackGame.Goal. + perfect(): Check if the user has reached BlackjackGame.Goal + + """ + + def __init__(self, name: nextcord.User | nextcord.Member) -> None: + """ + Create a new BlackjackPlayer instance. + + Args: + name (nextcord.User or Member): + The discord user representing this player + + """ + self.name: nextcord.User | nextcord.Member = name + self.hand: list[int] = [] + # TODO: make BlackjackPlayer.bet's type be 'int | None' + # and add a phase after owner does '!tablestart' where + # people make their bets + # grep for '805746791' when this is changed + self.bet: int = 10 + + def check_bust(self) -> bool: + """ + Check if the player has gone over BlackjackGame.Goal. + + Returns: + bool: Whether the user has gone over BlackjackGame.Goal. + + """ + return sum(self.hand) > BlackjackGame.Goal + + def perfect(self) -> bool: + """ + Check if the user has reached Goal, and therefore gotten Blackjack. + + In the actual game of Blackjack, getting Blackjack requires hitting + 21 with just your first two cards; for the sake of simplicity, use + this method for checking if the user has reached Goal at all. + + Returns: + bool: Whether the user has gotten Blackjack. + + """ + return sum(self.hand) == BlackjackGame.Goal + class BlackjackGame: """ @@ -43,29 +119,43 @@ class BlackjackGame: FaceVal (int): The value of a face card (J Q K) Goal (int): The desired score CardVals (tuple[int, ...]): Blackjack values for each card - user (nextcord.User or Member): The user who is playing this game - bet (int): The number of BeardlessBucks the user is betting + owner (nextcord.User or Member): The user who is owns this game + players (list[BlackjackPlayer]): The players in the game + turn_idx (int): an index into players that holds player to play + multiplayer (bool): Whether this match is multiplayer dealerUp (int): The card the dealer is showing face-up dealerSum (int): The running count of the dealer's cards deck (list): The cards remaining in the deck - hand (list): The list of cards the user has been dealt + started (bool): Whether the match/round started message (str): The report to be sent in the Discord channel Methods: - check_bust(): - Checks if the user has gone over Goal - deal_to_player(): - Deals the user a card + dealer_draw(): + Draw the dealers cards (at the end of the game). + _end_round(): + Ends a round after everyone plays their turn. + deal_to_current_player(): + Deals the player whose turn it is a card. + card_name(card): + Gives the human-friendly name of a given card. + ready_to_start(): + Checks if a multiplayer match is ready to start. + add_player(player): + Add a player to multiplayer blackjack match. + is_turn(player): + Checks whether it is the turn of a given player. deal_top_card(): Removes the top card from the deck. - perfect(): - Checks if the user has reached a Blackjack - starting_hand(): - Deals the user a starting hand of 2 cards - stay(): - Determines the game result after ending the game - card_name(card): - Gives the human-friendly name of a given card + _deal_cards(): + Deal the starting cards to the dealer and all players. + _start_game_regular(): + Starts a round where the dealer did not blackjack + _start_game_blackjack(): + Starts a round where the dealer blackjacked. + _dealer_blackjack_end_round(): + End a round where the dealer blackjacked. + start_game(): + Deal the user(s) a starting hand of 2 cards. """ @@ -74,11 +164,13 @@ class BlackjackGame: FaceVal = 10 Goal = 21 CardVals = (2, 3, 4, 5, 6, 7, 8, 9, 10, FaceVal, FaceVal, FaceVal, AceVal) + NumOfDecksInMatch = 4 def __init__( self, - user: nextcord.User | nextcord.Member, - bet: int, + owner: nextcord.User | nextcord.Member, + *, + multiplayer: bool, ) -> None: """ Create a new BlackjackGame instance. @@ -88,18 +180,135 @@ def __init__( reaching DealerSoftGoal. Args: - user (nextcord.User or Member): The user who is playing this game - bet (int): The number of BeardlessBucks the user is betting + owner (nextcord.User or Member): The user who is owning this game + in a singleplayer game the owner is also the only player. + in multiplayer the owner is the one who can start the round. + multiplayer (bool): Whether to make a multiplayer game """ - self.user = user - self.bet = bet + self.owner = BlackjackPlayer(owner) + self.players: list[BlackjackPlayer] = [self.owner] self.deck: list[int] = [] - self.deck.extend(BlackjackGame.CardVals * 4) - self.hand: list[int] = [] - self.dealerUp = self.deal_top_card() - self.dealerSum = self.dealerUp + self.deal_top_card() - self.message = self.starting_hand() + self.deck.extend(BlackjackGame.CardVals * 4 * 4) # 4 decks + # TODO: dealerUp should NEVER be None + # and dealerSum should NEVER be 0 + self.dealerUp: int | None = None + self.dealerSum: int = 0 + self.started: bool = False + self.turn_idx = 0 + self.multiplayer = multiplayer # only multiplayer games can be joined + if not multiplayer: + self.message = self.start_game() + else: + self.message = "Multiplayer Blackjack game created!\n" + + def dealer_draw(self) -> list[int]: + """ + Simulate the dealer drawing cards. + + Will draw cards until dealer is above DealerSoftGoal + Takes into accounts Ace overflow. + + Returns: + list[int]: the dealers final hand + + """ + assert self.dealerUp is not None + dealer_cards: list[int] = [ + self.dealerUp, self.dealerSum - self.dealerUp, + ] + while True: + if self.dealerSum > BlackjackGame.DealerSoftGoal: + if BlackjackGame.AceVal in dealer_cards: + self.dealerSum -= 10 + dealer_cards[dealer_cards.index(BlackjackGame.AceVal)] = 1 + else: + return dealer_cards + elif self.dealerSum == BlackjackGame.DealerSoftGoal: + return dealer_cards + dealt = self.deal_top_card() + dealer_cards.append(dealt) + self.dealerSum += dealt + + def _play_dealer_turn(self) -> str: + # dealer should only draw if there is at least 1 player that stayed + for p in self.players: + if not p.perfect() and not p.check_bust(): + if self.multiplayer: + report = "Round ended, the dealer will now play\n" + else: + report = "The dealer will now play\n" + dealer_cards: list[int] = self.dealer_draw() + report += "The dealer's cards are {} ".format( + ", ".join( + BlackjackGame.card_name(card) + for card in dealer_cards), + ) + report += f"for a total of {self.dealerSum}.\n" + return report + return "" + + def _end_round(self) -> str: + """ + End a round where the dealer blackjacked. + + Will draw the dealers cards only if at least one player stayed. + + Returns: + str: final report + + """ + assert self.dealerUp is not None + assert self.dealerSum != 0 + report = self._play_dealer_turn() + for p in self.players: + if p.perfect() or p.check_bust(): + # these have already been handled and reported + continue + report += f"{p.name.mention}, " + if sum(p.hand) > self.dealerSum and not p.check_bust(): + report += f"you're closer to {BlackjackGame.Goal} " + report += ( + f"with a sum of {sum(p.hand)}. {WinMsg}" + ) + write_money( + p.name, p.bet, writing=True, adding=True, + ) + elif sum(p.hand) == self.dealerSum: + report += ( + f"That ties your sum of {sum(p.hand)}. " + f"Your bet has been returned, {p.name.mention}." + ) + elif self.dealerSum > BlackjackGame.Goal: + report += ( + f"You have a sum of {sum(p.hand)}. " + f"The dealer busts. {WinMsg}" + ) + write_money( + p.name, p.bet, writing=True, adding=True, + ) + else: + report += ( + f"That's closer to {BlackjackGame.Goal} " + f"than your sum of {sum(p.hand)}. {LoseMsg}." + ) + write_money( + p.name, -p.bet, writing=True, adding=True, + ) + if not p.bet: + report += ( + "Unfortunately, you bet nothing, so this was all pointless." + ) + report += "\n" # trust me this is needed + if not self.multiplayer: + return report + self.started = False + self.dealerUp = None + self.dealerSum = 0 + for p in self.players: + p.hand = [] + report += "\nRound ended!" + return report @staticmethod def card_name(card: int) -> str: @@ -114,6 +323,9 @@ def card_name(card: int) -> str: """ if card == BlackjackGame.FaceVal: + # TODO: this can cause us to draw more of a single facecard + # than would exist in the card pool in a real game. + # fixing this is not simple return "a " + random.choice( (str(BlackjackGame.FaceVal), "Jack", "Queen", "King"), ) @@ -121,174 +333,294 @@ def card_name(card: int) -> str: return "an Ace" return "an 8" if card == 8 else ("a " + str(card)) # noqa: PLR2004 - def deal_top_card(self) -> int: + # NOTE: this is currently useless + # for more info grep for '805746791' + def ready_to_start(self) -> bool: """ - Remove and return the top card from the deck. + Check if a multiplayer match is ready to start. Returns: - int: The value of the top card of the deck. + bool: whether all players have placed a bet. """ - return self.deck.pop(random.randint(0, len(self.deck) - 1)) + assert self.multiplayer + return all(player.bet is not None for player in self.players) - def perfect(self) -> bool: + def add_player(self, player: nextcord.User | nextcord.Member) -> None: """ - Check if the user has reached Goal, and therefore gotten Blackjack. + Add a player to a multiplayer blackjack match. - In the actual game of Blackjack, getting Blackjack requires hitting - 21 with just your first two cards; for the sake of simplicity, use - this method for checking if the user has reached Goal at all. + Args: + player (nextcord.User | nextcord.Member): the player to add. + + """ + assert self.multiplayer + self.players.append(BlackjackPlayer(player)) + + def is_turn(self, player: BlackjackPlayer) -> bool: + """ + Check whether it is the turn of a given player. + + Args: + player (nextcord.User | nextcord.Member): the player to check. Returns: - bool: Whether the user has gotten Blackjack. + bool: whether is it the turn of 'player' """ - return sum(self.hand) == BlackjackGame.Goal + if self.turn_idx < 0 or self.turn_idx >= len(self.players): + return False + return self.players[self.turn_idx] == player - def starting_hand(self) -> str: + def deal_top_card(self) -> int: """ - Deal the user a starting hand of 2 cards. + Remove and return the top card from the deck. Returns: - str: The message to show the user. + int: The value of the top card of the deck. """ - self.hand.append(self.deal_top_card()) - self.hand.append(self.deal_top_card()) - message = ( - "Your starting hand consists of" - f" {BlackjackGame.card_name(self.hand[0])}" - f" and {BlackjackGame.card_name(self.hand[1])}." - f" Your total is {sum(self.hand)}. " - ) - if self.perfect(): - message += ( - f"You hit {BlackjackGame.Goal}! You win, {self.user.mention}!" - ) - else: + return self.deck.pop(random.randint(0, len(self.deck) - 1)) + + def _deal_cards(self) -> None: + """Deal the starting cards to the dealer and all players.""" + self.dealerUp = self.deal_top_card() + self.dealerSum = self.dealerUp + self.deal_top_card() + for p in self.players: + p.hand = [] + p.hand.append(self.deal_top_card()) + p.hand.append(self.deal_top_card()) + + def _dealer_blackjack_end_round(self) -> None: + """End a round where the dealer blackjacked.""" + assert self.dealerSum == self.Goal + if self.multiplayer: + self.turn_idx = len(self.players) + + def _start_game_blackjack(self) -> str: + """Play players' turns after the dealer draws blackjacks.""" + message = "The dealer blackjacked!\n" + for p in self.players: message += ( - f"The dealer is showing {self.dealerUp}," - " with one card face down. " + f"{p.name.mention} your starting hand consists of " + f"{p.hand[0]} and {p.hand[1]}. " ) - if self.check_bust(): - self.hand[1] = 1 - self.bet *= -1 - message = ( - "Your starting hand consists of two Aces." - " One of them will act as a 1. Your total is 12. " + if p.perfect(): + message += ( + "You tied with the dealer, your bet is returned.\n" ) - message += ( - "Type !hit to deal another card to yourself, or !stay" - f" to stop at your current total, {self.user.mention}." - ) + else: + message += ( + "You did not blackjack, you lose.\n" + ) + write_money(p.name, -p.bet, writing=True, adding=True) + self._dealer_blackjack_end_round() + message += "\nRound ended." return message - def deal_to_player(self) -> str: + def _start_game_regular(self) -> str: """ - Deal the user a single card. + Start a round where the dealer did not blackjack. + + Deals cards to all players. + Handles ace overflows and player blackjacks. Returns: - str: The message to show the user. + str: human readable report message. """ - dealt = self.deal_top_card() - self.hand.append(dealt) - self.message = ( - f"You were dealt {BlackjackGame.card_name(dealt)}," - f" bringing your total to {sum(self.hand)}. " + message = ( + f"The dealer is showing {self.dealerUp}, " + "with one card face down.\n" ) - if BlackjackGame.AceVal in self.hand and self.check_bust(): - for i, card in enumerate(self.hand): # pragma: no branch - if card == BlackjackGame.AceVal: - self.hand[i] = 1 - self.bet *= -1 - break - self.message += ( - "To avoid busting, your Ace will be treated as a 1." - f" Your new total is {sum(self.hand)}. " - ) - self.message += ( - "Your card values are {}. The dealer is" - " showing {}, with one card face down." - ).format(", ".join(str(card) for card in self.hand), self.dealerUp) - if self.check_bust(): - self.message += f" You busted. Game over, {self.user.mention}." - elif self.perfect(): - self.message += ( - f" You hit {BlackjackGame.Goal}! You win, {self.user.mention}!" - ) - else: - self.message += ( - " Type !hit to deal another card to yourself, or !stay" - f" to stop at your current total, {self.user.mention}." - ) - return self.message + append_help: bool = not self.multiplayer + for p in self.players: + if p.check_bust(): + if self.multiplayer: + append_help = True + p.hand[1] = 1 + message += ( + f"{p.name.mention} your starting hand consists of two Aces." + " One of them will act as a 1. Your total is 12.\n" + ) + else: + message += ( + f"{p.name.mention} your starting hand consists of " + f"{BlackjackGame.card_name(p.hand[0])} " + f"and {BlackjackGame.card_name(p.hand[1])}. " + ) + if p.perfect(): + if not self.multiplayer: + append_help = False + elif p == self.players[self.turn_idx]: + self.advance_turn() + message += f"You hit {BlackjackGame.Goal}! {WinMsg}.\n" + write_money(p.name, p.bet, writing=True, adding=True) + else: + if self.multiplayer: + append_help = True + message += f"Your total is {sum(p.hand)}.\n" + if append_help: + if not self.multiplayer: + message += GameHelpMsg + else: + message += ( + f"\n{self.players[self.turn_idx].name.mention} " + f"it is your turn! {GameHelpMsg}" + ) + return message - def check_bust(self) -> bool: + def start_game(self) -> str: + """ + Deal the user(s) a starting hand of 2 cards. + + Returns: + str: Human readable report. + + """ + self.turn_idx = 0 + self.started = True + self._deal_cards() + if self.dealerSum == BlackjackGame.Goal: + return self._start_game_blackjack() + return self._start_game_regular() + + def advance_turn(self) -> None: """ - Check if a user has gone over Goal. + End current player's turn. - If so, invert their bet to facilitate subtracting it from their total. + Skips over all players that blackjacked. + """ + while True: + self.turn_idx += 1 + if self.turn_idx == len(self.players): + return + player = self.players[self.turn_idx] + # you can't bust without ever dealing + assert not player.check_bust() + # skip over all players that can't play + if not player.perfect(): + return + + def round_over(self) -> bool: + """ + Check if the round ended. Returns: - bool: Whether the user has gone over Goal. + bool: if the round ended """ - if sum(self.hand) > BlackjackGame.Goal: - self.bet *= -1 - return True - return False + assert self.turn_idx <= len(self.players) + return self.turn_idx == len(self.players) - def stay(self) -> int: + def deal_current_player(self) -> str: """ - End the game. + Deal the player whose turn it is a single card. Returns: - int: 1 if user's balance changed; else, 0. - - """ - change = 1 - while self.dealerSum < BlackjackGame.DealerSoftGoal: - self.dealerSum += self.deal_top_card() - self.message = "The dealer has a total of {}." - if sum(self.hand) > self.dealerSum and not self.check_bust(): - self.message += f" You're closer to {BlackjackGame.Goal}" - self.message += ( - " with a sum of {}. You win! Your winnings" - " have been added to your balance, {}." + str: report + + """ + assert self.started + dealt = self.deal_top_card() + dealt_card = dealt + player = self.players[self.turn_idx] + player.hand.append(dealt) + new_hand = player.hand + append_help: bool = True + report = ( + f"{player.name.mention} you were dealt " + f"{BlackjackGame.card_name(dealt_card)}, " + "bringing your total to " + ) + if BlackjackGame.AceVal in player.hand and player.check_bust(): + for i, card in enumerate(player.hand): # pragma: no branch + if card == BlackjackGame.AceVal: + player.hand[i] = 1 + break + report += ( + f"{sum(new_hand) + 10}. " + "To avoid busting, your Ace will be treated as a 1. " + f"Your new total is {sum(new_hand)}. " ) - elif sum(self.hand) == self.dealerSum: - change = 0 - self.message += ( - " That ties your sum of {}. Your bet has been returned, {}." + else: + report += ( + f"{sum(new_hand)}. " + "Your card values are {}. The dealer is" + " showing {}, with one card face down." + ).format(", ".join(str(card) for card in new_hand), self.dealerUp) + if player.check_bust(): + append_help = False + write_money( + player.name, -player.bet, writing=True, adding=True, ) - elif self.dealerSum > BlackjackGame.Goal: - self.message += ( - " You have a sum of {}. The dealer busts. You win!" - " Your winnings have been added to your balance, {}." + self.advance_turn() + report += " You busted. Game over." + if not self.round_over(): + report += ( + f"\n{self.players[self.turn_idx].name.mention}, " + "it is your turn.\n" + ) + elif player.perfect(): + append_help = False + write_money( + player.name, player.bet, writing=True, adding=True, ) - else: - self.message += f" That's closer to {BlackjackGame.Goal}" - self.message += ( - " than your sum of {}. You lose. Your loss" - " has been deducted from your balance, {}." + report += ( + f" You hit {BlackjackGame.Goal}! " + f"{WinMsg}, {player.name.mention}.\n" ) - self.bet *= -1 - self.message = self.message.format( - self.dealerSum, sum(self.hand), self.user.mention, - ) - if not self.bet: - self.message += ( - " Unfortunately, you bet nothing, so this was all pointless." + self.advance_turn() + if append_help: + report += f" {GameHelpMsg}" + elif self.round_over(): + report += self._end_round() + return report + + def stay_current_player(self) -> str: + """ + Stay the current player. + + if all other players' actions have been exhausted, end the round. + + Returns: + bool: the round has ended. + + """ + report = f"{self.players[self.turn_idx].name.mention} you stayed.\n" + self.advance_turn() + if self.round_over(): + report += self._end_round() + else: + report += ( + f"{self.players[self.turn_idx].name.mention}, " + "it is not your turn.\n" ) - return change + return report + + def get_player( + self, player: nextcord.User | nextcord.Member, + ) -> BlackjackPlayer | None: + """ + Get player by name if in current match. + + Args: + player (nextcord.User or nextcord.Member): the player to query + + Returns: + BlackjackPlayer: the player if in match or None. + + """ + for p in self.players: + if p.name is player: + return p + return None class MoneyFlags(Enum): """Enum for additional readability in the writeMoney method.""" - NotEnoughBucks = -2 - CommaInUsername = -1 + NotEnoughBucks = -1 BalanceUnchanged = 0 BalanceChanged = 1 Registered = 2 @@ -300,7 +632,7 @@ def write_money( *, writing: bool, adding: bool, -) -> tuple[MoneyFlags, str | int | None]: +) -> tuple[MoneyFlags, int]: """ Check or modify a user's BeardlessBucks balance. @@ -311,24 +643,21 @@ def write_money( adding (bool): Whether to add to or overwrite member's balance Returns: - tuple[MoneyFlags, str | int | None]: A tuple containing: + tuple[MoneyFlags, int]: A tuple containing: MoneyFlags: enum representing the result of calling the method - str or int or None: an additional report, if necessary. + int: the current money in the user's bank after the operation """ - if "," in member.name: - return MoneyFlags.CommaInUsername, CommaWarn.format(member.mention) + assert "," not in member.name with Path("resources/money.csv").open("r", encoding="UTF-8") as csv_file: for row in csv.reader(csv_file, delimiter=","): if str(member.id) == row[0]: # found member if isinstance(amount, str): # for people betting all amount = -int(row[1]) if amount == "-all" else int(row[1]) - new_bank: str | int = str( - int(row[1]) + amount if adding else amount, - ) - if writing and row[1] != new_bank: + new_bank: int = int(row[1]) + amount if adding else amount + if writing and row[1] != str(new_bank): if int(row[1]) + amount < 0: - return MoneyFlags.NotEnoughBucks, None + return MoneyFlags.NotEnoughBucks, int(row[1]) new_line = ",".join((row[0], str(new_bank), str(member))) result = MoneyFlags.BalanceChanged else: @@ -349,13 +678,7 @@ def write_money( with Path("resources/money.csv").open("a", encoding="UTF-8") as f: f.write(f"\r\n{member.id},300,{member}") - return ( - MoneyFlags.Registered, - ( - "Successfully registered. You have 300" - f" BeardlessBucks, {member.mention}." - ), - ) + return MoneyFlags.Registered, 300 def register(target: nextcord.User | nextcord.Member) -> nextcord.Embed: @@ -370,9 +693,7 @@ def register(target: nextcord.User | nextcord.Member) -> nextcord.Embed: """ result, bonus = write_money(target, 300, writing=False, adding=False) - report = bonus if result in { - MoneyFlags.CommaInUsername, MoneyFlags.Registered, - } else ( + report = bonus if result == MoneyFlags.Registered else ( "You are already in the system! Hooray! You" f" have {bonus} BeardlessBucks, {target.mention}." ) @@ -413,13 +734,15 @@ def balance( f"{bal_target.mention}'s balance is {bonus} BeardlessBucks." ) else: - report = str(bonus) if result in { - MoneyFlags.CommaInUsername, MoneyFlags.Registered, - } else "Error!" + report = ( + NewUserMsg.format(msg.author.mention) + if result == MoneyFlags.Registered + else "Error!" + ) return bb_embed("BeardlessBucks Balance", report) -def reset(target: nextcord.User | nextcord.Member) -> nextcord.Embed: +def reset(target: nextcord.User | nextcord.Member) -> str: """ Reset a user's Beardless balance to 200. @@ -427,15 +750,15 @@ def reset(target: nextcord.User | nextcord.Member) -> nextcord.Embed: target (nextcord.User or Member): The user to reset Returns: - nextcord.Embed: the report of the target's balance reset. + str: the report of the target's balance reset. """ - result, bonus = write_money(target, 200, writing=True, adding=False) - report = bonus if result in { - MoneyFlags.CommaInUsername, MoneyFlags.Registered, - } else f"You have been reset to 200 BeardlessBucks, {target.mention}." - assert isinstance(report, str) - return bb_embed("BeardlessBucks Reset", report) + result, _ = write_money(target, 200, writing=True, adding=False) + if result == MoneyFlags.Registered: + report = NewUserMsg.format(target.mention) + else: + report = f"You have been reset to 200 BeardlessBucks, {target.mention}." + return report def leaderboard( @@ -513,10 +836,8 @@ def flip(author: nextcord.User | nextcord.Member, bet: str | int) -> str: """ heads = random.randint(0, 1) - report = ( - "Invalid bet. Please choose a number greater than or equal" - " to 0, or enter \"all\" to bet your whole balance, {}." - ) + report = InvalidBetMsg + assert "," not in author.name if bet == "all": if not heads: bet = "-all" @@ -532,9 +853,6 @@ def flip(author: nextcord.User | nextcord.Member, bet: str | int) -> str: result, bank = write_money(author, 300, writing=False, adding=False) if result == MoneyFlags.Registered: report = NewUserMsg - elif result == MoneyFlags.CommaInUsername: - assert isinstance(bank, str) - report = bank elif isinstance(bet, int) and isinstance(bank, int) and bet > bank: report = ( "You do not have enough BeardlessBucks to bet that much, {}!" @@ -543,77 +861,111 @@ def flip(author: nextcord.User | nextcord.Member, bet: str | int) -> str: if isinstance(bet, int) and not heads: bet *= -1 result = write_money(author, bet, writing=True, adding=True)[0] - report = ( - "Heads! You win! Your winnings have" - " been added to your balance, {}." - ) if heads else ( - "Tails! You lose! Your losses have been" - " deducted from your balance, {}." - ) + report = f"Heads! {WinMsg}" if heads else f"Tails! {LoseMsg}" + report += f", {author.mention}.\n" if result == MoneyFlags.BalanceUnchanged: report += ( - " Or, they would have been, if" + "Or, they would have been, if" " you had actually bet anything." ) return report.format(author.mention) +def can_make_bet( + user: nextcord.User | nextcord.Member, + bet: str | int, +) -> tuple[bool, str | None]: + if isinstance(bet, str): + if bet == "all": + return True, None + try: + bet_num: int = int(bet) + except ValueError: + return False, InvalidBetMsg.format(user.mention) + if bet_num < 0: + return False, InvalidBetMsg.format(user.mention) + + result, bank = write_money(user, 300, writing=False, adding=False) + if result == MoneyFlags.Registered: + return True, NewUserMsg.format(user.name) + if isinstance(bank, int) and bet_num > bank: + return False, ( + "You do not have enough BeardlessBucks to " + f"bet that much, {user.mention}!" + ) + + return True, None + + +def make_bet( + author: nextcord.User | nextcord.Member, + game: BlackjackGame, + bet: str | int, # expected to be either "all" or a number +) -> tuple[str, int]: + report = InvalidBetMsg + result, bank = write_money(author, 300, writing=False, adding=False) + if result == MoneyFlags.Registered: + report = NewUserMsg + elif isinstance(bet, int) and isinstance(bank, int): + if bet > bank: + report = ( + "You do not have enough BeardlessBucks to bet that much, {}!" + ) + else: + report = game.message + elif bet == "all": + assert bank is not None + bet = bank + report = game.message + return report, int(bet) # this cast should work + + def blackjack( - author: nextcord.User | nextcord.Member, bet: str | int, + author: nextcord.User | nextcord.Member, + bet: str | int | None, ) -> tuple[str, BlackjackGame | None]: """ Gamble a certain number of BeardlessBucks on blackjack. Args: author (nextcord.User or Member): The user who is gambling - bet (str): The amount author is wagering + bet (str | int | None): The amount author is wagering. + if None then a multiplayer game is created & returned Returns: str: A report of the outcome and how author's balance changed. BlackjackGame or None: If there is still a game to play, returns the object representing the game of blackjack - author is playing. Else, None. + author is playing. Else if game has ended in blackjack, None. """ game = None - report = ( - "Invalid bet. Please choose a number greater than or equal" - " to 0, or enter \"all\" to bet your whole balance, {}." - ) - if bet != "all": + if bet is None: + # bet being None means user wants a multiplayer game + game = BlackjackGame(author, multiplayer=True) + report = game.message + return report.format(author.mention), game + if isinstance(bet, str) and bet != "all": try: bet = int(bet) except ValueError: - bet = -1 + return InvalidBetMsg.format(author.mention), game if ( (isinstance(bet, str) and bet == "all") or (isinstance(bet, int) and bet >= 0) ): - result, bank = write_money(author, 300, writing=False, adding=False) - if result == MoneyFlags.Registered: - report = NewUserMsg - elif result == MoneyFlags.CommaInUsername: - assert isinstance(bank, str) - report = bank - elif isinstance(bet, int) and isinstance(bank, int) and bet > bank: - report = ( - "You do not have enough BeardlessBucks to bet that much, {}!" - ) - else: - if bet == "all": - assert bank is not None - bet = bank - game = BlackjackGame(author, int(bet)) - report = game.message - if game.perfect(): - write_money(author, bet, writing=True, adding=True) - game = None + game = BlackjackGame(author, multiplayer=False) + report, bet = make_bet(author, game, bet) + player = game.players[0] + player.bet = bet + if player.perfect(): + game = None return report.format(author.mention), game -def active_game( +def player_in_game( games: list[BlackjackGame], author: nextcord.User | nextcord.Member, -) -> BlackjackGame | None: +) -> tuple[BlackjackGame, BlackjackPlayer] | None: """ Check if a user has an active game of Blackjack. @@ -624,9 +976,13 @@ def active_game( author (nextcord.User or Member): The user who is gambling Returns: - BlackjackGame or None: The user's current Blackjack game, - if one exists. Else, None. + tuple[BlackjackGame, BlackjackPlayer] or None: The player associated + with the discord account and the game they're in if they're in one. + Else, None. """ - game = [g for g in games if g.user == author] - return game[0] if game else None + for game in games: + player = game.get_player(author) + if player is not None: + return game, player + return None diff --git a/misc.py b/misc.py index cbf09e5..b0b1f94 100644 --- a/misc.py +++ b/misc.py @@ -986,6 +986,28 @@ def get_last_numeric_char(duration: str) -> int: return len(duration) +# TODO: merge with process_mute_target +async def process_command_target( + ctx: BotContext, target: str | None, bot: commands.Bot, +) -> nextcord.Member | None: + if not target: + await ctx.send(f"Please specify a target, {ctx.author.mention}.") + return None + try: + command_target = await commands.MemberConverter().convert(ctx, target) + except commands.MemberNotFound: + await ctx.send(embed=bb_embed( + "Beardless Bot Join", + "Invalid target! Target must be a mention or user ID.", + )) + return None + if bot.user is not None and command_target.id == bot.user.id: + await ctx.send("I'm not in match, idiot.") + return None + return command_target + + +# TODO: merge with process_command_target async def process_mute_target( ctx: BotContext, target: str | None, bot: commands.Bot, ) -> nextcord.Member | None: diff --git a/resources/images/coverage.svg b/resources/images/coverage.svg index 4f6c255..07780ac 100644 --- a/resources/images/coverage.svg +++ b/resources/images/coverage.svg @@ -1 +1 @@ -coverage: 92.00%coverage92.00% \ No newline at end of file +coverage: 88.00%coverage88.00% \ No newline at end of file diff --git a/resources/images/docstr-coverage.svg b/resources/images/docstr-coverage.svg index 935903b..2b143db 100644 --- a/resources/images/docstr-coverage.svg +++ b/resources/images/docstr-coverage.svg @@ -8,13 +8,13 @@ - + docstr-coverage docstr-coverage - 37% - 37% + 40% + 40% \ No newline at end of file diff --git a/resources/images/tests.svg b/resources/images/tests.svg index 450afde..299494c 100644 --- a/resources/images/tests.svg +++ b/resources/images/tests.svg @@ -1 +1 @@ -tests: 178tests178 \ No newline at end of file +tests: 181/182tests181/182 \ No newline at end of file diff --git a/resources/money.csv b/resources/money.csv index 27f91a4..84525cc 100644 --- a/resources/money.csv +++ b/resources/money.csv @@ -437,7 +437,7 @@ 912317264060096534,200,DAnnyBoyTriple1#9858 759156148795867167,280,ily jk.#5743 776233398954885140,300,Lev's Quizbowl Bot#1390 -123456789,300,Test#0000 +123456789,420,testname#0000 579482715888287754,300,cotton#0076 723044829025796147,600,wolves#2046 293132125190750208,290,Kit#1998 @@ -577,4 +577,5 @@ 1349066861706481694,0,munatic. 916318405915709440,300,yoshi1236007 880074658710442004,300,haiferwd -612953019901935616,200,itami.it \ No newline at end of file +612953019901935616,200,itami.it +1,300,not#0000 \ No newline at end of file