From 48d70e4fea08c5cd5c4fc3e191adb32eb7d028d8 Mon Sep 17 00:00:00 2001 From: Logan Scott Date: Mon, 16 Feb 2026 21:16:14 -0500 Subject: [PATCH 1/2] feat: add slots expanded game route --- casino/games/slots/__init__.py | 3 +- casino/games/slots/slots_expanded.py | 293 +++++++++++++++++++++++++++ casino/main.py | 1 + 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 casino/games/slots/slots_expanded.py diff --git a/casino/games/slots/__init__.py b/casino/games/slots/__init__.py index bbf7f94..0858c2a 100644 --- a/casino/games/slots/__init__.py +++ b/casino/games/slots/__init__.py @@ -1,3 +1,4 @@ from .slots import play_slots +from .slots_expanded import play_slots_expanded -__all__ = ["play_slots"] +__all__ = ["play_slots", "play_slots_expanded"] diff --git a/casino/games/slots/slots_expanded.py b/casino/games/slots/slots_expanded.py new file mode 100644 index 0000000..d696e6e --- /dev/null +++ b/casino/games/slots/slots_expanded.py @@ -0,0 +1,293 @@ +import random +import time +from typing import Literal + +from casino.accounts import Account +from casino.types import GameContext +from casino.utils import clear_screen, cprint, cinput, display_topbar + +SlotsMenuChoice = Literal["respin", "change_bet", "quit"] + +SLOTS_HEADER = """ +┌───────────────────────────────┐ +│ ♠ S L O T S ♠ │ +└───────────────────────────────┘ +""" +HEADER_OPTIONS = { + "header": SLOTS_HEADER, + "margin": 1, +} + +BET_PROMPT = "How much would you like to bet?" +INVALID_BET_MSG = "That's not a valid bet." +INVALID_INPUT_MSG = "Invalid input. Please try again." + +SEC_BTWN_SPIN = 0.1 +TOTAL_SPINS = 10 +WIN_PROBABILITY = 0.2 +HIGH_VALUE_PROBABILITY = 0.05 + + +def get_slots_menu_prompt(ctx: GameContext, bet_amount: int) -> str: + """Generate slots menu prompt.""" + if ctx.account.balance < ctx.config.slots_min_line_bet: + return f"[Q]uit" + if ctx.account.balance < bet_amount: + return f"[C]hange Bet [Q]uit" + else: + return f"[R]espin [C]hange Bet [Q]uit" + + +def generate_payout_legend( + low_items: list[str], + high_items: list[str], +) -> str: + """Generate payout legend.""" + low_items_str = " | ".join(low_items) + high_items_str = " | ".join(high_items) + max_len = max(len(low_items_str), len(high_items_str)) + low_str = f"Matching {low_items_str.ljust(max_len)} | x1.5" + high_str = f"Matching {high_items_str.ljust(max_len)} | x5.0" + return f"{low_str}\n{high_str}" + + +LOW_ITEMS = ["A", "B", "C"] +HIGH_ITEMS = ["D"] +ALL_ITEMS = LOW_ITEMS + HIGH_ITEMS +PAYOUT_LEGEND = generate_payout_legend(LOW_ITEMS, HIGH_ITEMS) + + +# Currently 1 pay line, goal is to have several and: +# - implement pattern patching for wins across lines +# - be able to bet across lines independently +# - then maybe special lines + + +def get_rand_item() -> str: + return random.choice(ALL_ITEMS) + + +def print_spin(items: tuple[str, str, str], frame: int) -> None: + legend_lines = PAYOUT_LEGEND.splitlines() + low_line = legend_lines[0] if len(legend_lines) > 0 else "" + high_line = legend_lines[1] if len(legend_lines) > 1 else "" + + def pad_line(line: str, width: int = 38) -> str: + return line.ljust(width) + + low_line = pad_line(low_line) + high_line = pad_line(high_line) + + match frame: + case 0 | 5: + cprint(f""" +┌───────────────────────────────────────┐ +│ ♦ T E R M I N A L C A S I N O ♦ │ +│───────────────────────────────────────│ + │ │┌───┐ + │ ┌───────┐ ┌───────┐ ┌───────┐ ││ │ + │ │ │ │ │ │ │ │└───┘ + │ └───────┘ └───────┘ └───────┘ │ │ │ + │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ + │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │ │ │ + │ └───────┘ └───────┘ └───────┘ │ │ │ + │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ + │ │ │ │ │ │ │ │─┘ │ + │ └───────┘ └───────┘ └───────┘ │───┘ +│ │ +│───────────────────────────────────────│ +│ PAYOUTS │ +│ │ +│ {low_line}│ +│ {high_line}│ +│ │ +└───────────────────────────────────────┘ + """.strip()) + + case 1 | 4: + cprint(f""" +┌───────────────────────────────────────┐ +│ ♦ T E R M I N A L C A S I N O ♦ │ +│───────────────────────────────────────│ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ + │ │ │ │ │ │ │ │┌───┐ + │ └───────┘ └───────┘ └───────┘ ││ │ + │ ┌───────┐ ┌───────┐ ┌───────┐ │└───┘ + │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │ │ │ + │ └───────┘ └───────┘ └───────┘ │ │ │ + │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ + │ │ │ │ │ │ │ │─┘ │ + │ └───────┘ └───────┘ └───────┘ │───┘ +│ │ +│───────────────────────────────────────│ +│ PAYOUTS │ +│ │ +│ {low_line}│ +│ {high_line}│ +│ │ +└───────────────────────────────────────┘ + """.strip()) + + case 2 | 3: + cprint(f""" +┌───────────────────────────────────────┐ +│ ♦ T E R M I N A L C A S I N O ♦ │ +│───────────────────────────────────────│ +│ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ │ │ │ │ │ │ +│ └───────┘ └───────┘ └───────┘ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ + │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │┌───┐ + │ └───────┘ └───────┘ └───────┘ ││ │ + │ ┌───────┐ ┌───────┐ ┌───────┐ │└───┘ + │ │ │ │ │ │ │ │─┘ │ + │ └───────┘ └───────┘ └───────┘ │───┘ +│ │ +│───────────────────────────────────────│ +│ PAYOUTS │ +│ │ +│ {low_line}│ +│ {high_line}│ +│ │ +└───────────────────────────────────────┘ + """.strip()) + + +def get_bet_amount(ctx: GameContext) -> int: + """Prompt user for bet amount.""" + account = ctx.account + min_bet = ctx.config.slots_min_line_bet + while True: + cprint(PAYOUT_LEGEND + "\n\n") + bet_str = cinput(BET_PROMPT).strip() + try: + bet = int(bet_str) + except ValueError: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + cprint(INVALID_BET_MSG) + continue + if bet < min_bet: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + cprint(f"Each pay line requires at least {min_bet} chips.") + continue + if bet > account.balance: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + cprint(f"You only have {account.balance} chips. Please try again.") + continue + return bet + + +def get_player_choice( + ctx: GameContext, + items: tuple[str, str, str], + bet_amount: int, +) -> SlotsMenuChoice: + """Prompt user for slots menu choice.""" + account = ctx.account + min_bet = ctx.config.slots_min_line_bet + first_iter = True + while True: + if not first_iter: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + print_spin(items, 0) + cprint(INVALID_INPUT_MSG) + first_iter = False + menu_prompt = get_slots_menu_prompt(ctx, bet_amount) + player_input = cinput(menu_prompt).strip() + if player_input == "": + continue + if player_input in "qQ": + return "quit" + elif player_input in "rR": + if account.balance < bet_amount: + continue + return "respin" + elif player_input in "cC": + if account.balance < min_bet: + continue + return "change_bet" + + +def spin_animation( + account: Account, + total_spins: int = TOTAL_SPINS, + sec_btwn_spins: float = SEC_BTWN_SPIN, +) -> None: + """Animate the spin of the slot machine.""" + # Animate pulling the arm + for i in range(5): + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + print_spin((get_rand_item(), get_rand_item(), get_rand_item()), i) + time.sleep(sec_btwn_spins) + # Animate the slots spinning + for _ in range(total_spins): + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + print_spin((get_rand_item(), get_rand_item(), get_rand_item()), 0) + time.sleep(sec_btwn_spins) + + +def play_slots_expanded(ctx: GameContext) -> None: + """Play slots game.""" + account = ctx.account + min_bet = ctx.config.slots_min_line_bet + take_new_bet = True + bet_amount = 0 + while True: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + if take_new_bet or bet_amount > account.balance: + if account.balance < min_bet: + cprint("You don't have enough money to make a bet.\n\n") + cinput("Press Enter to continue...") + return + bet_amount = get_bet_amount(ctx) + take_new_bet = False + + spin_animation(account) + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + + # Display final spin result + items: tuple[str, str, str] + rng = random.random() + if rng <= WIN_PROBABILITY: + money_gain = 0 + if rng <= HIGH_VALUE_PROBABILITY: + win_item = HIGH_ITEMS[random.randint(0, len(HIGH_ITEMS) - 1)] + money_gain = bet_amount * 5 + else: + win_item = LOW_ITEMS[random.randint(0, len(LOW_ITEMS) - 1)] + money_gain = int(bet_amount * 1.5) + items = (win_item, win_item, win_item) + account.deposit(money_gain) + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + print_spin(items, 0) + cprint(f"MATCH: +{money_gain} chips") + else: + items = (get_rand_item(), get_rand_item(), get_rand_item()) + while len(set(items)) == 1: + items = (get_rand_item(), get_rand_item(), get_rand_item()) + clear_screen() + account.withdraw(bet_amount) + display_topbar(account, **HEADER_OPTIONS) + print_spin(items, 0) + cprint(f"NO MATCH: -{bet_amount} chips") + + # Choose what to do after spin + choice = get_player_choice(ctx, items, bet_amount) + match choice: + case "quit": + return + case "change_bet": + take_new_bet = True + case "respin": + continue diff --git a/casino/main.py b/casino/main.py index 7404ad2..3d230c9 100644 --- a/casino/main.py +++ b/casino/main.py @@ -30,6 +30,7 @@ "blackjack (U.S.)": games.blackjack.play_blackjack, "blackjack (E.U.)": games.blackjack.play_european_blackjack, "slots": games.slots.play_slots, + "slots (Expanded)": games.slots.play_slots_expanded, "poker": games.poker.play_poker, "roulette": games.roulette.play_roulette, "uno": games.uno.play_uno, From f74dabc6ca701ca6f21e369829abba556daba6d6 Mon Sep 17 00:00:00 2001 From: Logan Scott Date: Mon, 16 Feb 2026 22:30:32 -0500 Subject: [PATCH 2/2] feat: add slots_expanded with per-row betting on 3 horizontal paylines --- casino/games/slots/slots_expanded.py | 169 ++++++++++++++++----------- 1 file changed, 98 insertions(+), 71 deletions(-) diff --git a/casino/games/slots/slots_expanded.py b/casino/games/slots/slots_expanded.py index d696e6e..eb347ea 100644 --- a/casino/games/slots/slots_expanded.py +++ b/casino/games/slots/slots_expanded.py @@ -1,7 +1,6 @@ import random import time from typing import Literal - from casino.accounts import Account from casino.types import GameContext from casino.utils import clear_screen, cprint, cinput, display_topbar @@ -18,21 +17,19 @@ "margin": 1, } -BET_PROMPT = "How much would you like to bet?" -INVALID_BET_MSG = "That's not a valid bet." +BET_PROMPT = "How much would you like to bet on each row? (top,mid,bottom) e.g. 10,15,20" +INVALID_BET_MSG = "That's not a valid bet. Example: 10,15,20" INVALID_INPUT_MSG = "Invalid input. Please try again." SEC_BTWN_SPIN = 0.1 TOTAL_SPINS = 10 -WIN_PROBABILITY = 0.2 -HIGH_VALUE_PROBABILITY = 0.05 - +PAYLINES = 3 -def get_slots_menu_prompt(ctx: GameContext, bet_amount: int) -> str: +def get_slots_menu_prompt(ctx: GameContext, total_bet: int) -> str: """Generate slots menu prompt.""" if ctx.account.balance < ctx.config.slots_min_line_bet: return f"[Q]uit" - if ctx.account.balance < bet_amount: + if ctx.account.balance < total_bet: return f"[C]hange Bet [Q]uit" else: return f"[R]espin [C]hange Bet [Q]uit" @@ -57,17 +54,21 @@ def generate_payout_legend( PAYOUT_LEGEND = generate_payout_legend(LOW_ITEMS, HIGH_ITEMS) -# Currently 1 pay line, goal is to have several and: -# - implement pattern patching for wins across lines -# - be able to bet across lines independently +# Currently 3 horizontal pay lines, goal is to have several and: +# - implement pattern patching for wins across columns and diagonals # - then maybe special lines - def get_rand_item() -> str: return random.choice(ALL_ITEMS) +def get_rand_grid(): + return ( + (get_rand_item(), get_rand_item(), get_rand_item()), + (get_rand_item(), get_rand_item(), get_rand_item()), + (get_rand_item(), get_rand_item(), get_rand_item()), + ) -def print_spin(items: tuple[str, str, str], frame: int) -> None: +def print_spin(grid, frame: int) -> None: legend_lines = PAYOUT_LEGEND.splitlines() low_line = legend_lines[0] if len(legend_lines) > 0 else "" high_line = legend_lines[1] if len(legend_lines) > 1 else "" @@ -86,13 +87,13 @@ def pad_line(line: str, width: int = 38) -> str: │───────────────────────────────────────│ │ │┌───┐ │ ┌───────┐ ┌───────┐ ┌───────┐ ││ │ - │ │ │ │ │ │ │ │└───┘ + │ │{grid[0][0].center(7)}│ │{grid[0][1].center(7)}│ │{grid[0][2].center(7)}│ │└───┘ │ └───────┘ └───────┘ └───────┘ │ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ - │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │ │ │ + │ - │{grid[1][0].center(7)}│ │{grid[1][1].center(7)}│ │{grid[1][2].center(7)}│ - │ │ │ │ └───────┘ └───────┘ └───────┘ │ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ - │ │ │ │ │ │ │ │─┘ │ + │ │{grid[2][0].center(7)}│ │{grid[2][1].center(7)}│ │{grid[2][2].center(7)}│ │─┘ │ │ └───────┘ └───────┘ └───────┘ │───┘ │ │ │───────────────────────────────────────│ @@ -111,13 +112,13 @@ def pad_line(line: str, width: int = 38) -> str: │───────────────────────────────────────│ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ - │ │ │ │ │ │ │ │┌───┐ + │ │{grid[0][0].center(7)}│ │{grid[0][1].center(7)}│ │{grid[0][2].center(7)}│ │┌───┐ │ └───────┘ └───────┘ └───────┘ ││ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │└───┘ - │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │ │ │ + │ - │{grid[1][0].center(7)}│ │{grid[1][1].center(7)}│ │{grid[1][2].center(7)}│ - │ │ │ │ └───────┘ └───────┘ └───────┘ │ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │ │ - │ │ │ │ │ │ │ │─┘ │ + │ │{grid[2][0].center(7)}│ │{grid[2][1].center(7)}│ │{grid[2][2].center(7)}│ │─┘ │ │ └───────┘ └───────┘ └───────┘ │───┘ │ │ │───────────────────────────────────────│ @@ -136,13 +137,13 @@ def pad_line(line: str, width: int = 38) -> str: │───────────────────────────────────────│ │ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ -│ │ │ │ │ │ │ │ +│ │{grid[0][0].center(7)}│ │{grid[0][1].center(7)}│ │{grid[0][2].center(7)}│ │ │ └───────┘ └───────┘ └───────┘ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │ - │ - │{items[0].center(7)}│ │{items[1].center(7)}│ │{items[2].center(7)}│ - │┌───┐ + │ - │{grid[1][0].center(7)}│ │{grid[1][1].center(7)}│ │{grid[1][2].center(7)}│ - │┌───┐ │ └───────┘ └───────┘ └───────┘ ││ │ │ ┌───────┐ ┌───────┐ ┌───────┐ │└───┘ - │ │ │ │ │ │ │ │─┘ │ + │ │{grid[2][0].center(7)}│ │{grid[2][1].center(7)}│ │{grid[2][2].center(7)}│ │─┘ │ │ └───────┘ └───────┘ └───────┘ │───┘ │ │ │───────────────────────────────────────│ @@ -154,58 +155,66 @@ def pad_line(line: str, width: int = 38) -> str: └───────────────────────────────────────┘ """.strip()) - -def get_bet_amount(ctx: GameContext) -> int: - """Prompt user for bet amount.""" +def get_line_bets(ctx: GameContext) -> tuple[int, int, int]: + """Prompt user for bet amount per row.""" account = ctx.account min_bet = ctx.config.slots_min_line_bet while True: cprint(PAYOUT_LEGEND + "\n\n") - bet_str = cinput(BET_PROMPT).strip() + raw = cinput(BET_PROMPT).strip() + parts = [p.strip() for p in raw.split(",")] + if len(parts) != PAYLINES: + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + cprint(INVALID_BET_MSG) + continue try: - bet = int(bet_str) + bets = tuple(int(p) for p in parts) except ValueError: clear_screen() display_topbar(account, **HEADER_OPTIONS) cprint(INVALID_BET_MSG) continue - if bet < min_bet: + if any(b < 0 for b in bets): + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + cprint("Bets cannot be negative.") + continue + if sum(bets) < min_bet: clear_screen() display_topbar(account, **HEADER_OPTIONS) - cprint(f"Each pay line requires at least {min_bet} chips.") + cprint(f"You must bet at least {min_bet} chips.") continue - if bet > account.balance: + total_bet = sum(bets) + if total_bet > account.balance: clear_screen() display_topbar(account, **HEADER_OPTIONS) - cprint(f"You only have {account.balance} chips. Please try again.") + cprint(f"You need {total_bet} chips total. You have {account.balance}.") continue - return bet + return bets -def get_player_choice( - ctx: GameContext, - items: tuple[str, str, str], - bet_amount: int, -) -> SlotsMenuChoice: +def get_player_choice(ctx: GameContext, grid, total_bet: int) -> SlotsMenuChoice: """Prompt user for slots menu choice.""" account = ctx.account min_bet = ctx.config.slots_min_line_bet first_iter = True + while True: if not first_iter: clear_screen() display_topbar(account, **HEADER_OPTIONS) - print_spin(items, 0) + print_spin(grid, 0) cprint(INVALID_INPUT_MSG) first_iter = False - menu_prompt = get_slots_menu_prompt(ctx, bet_amount) + menu_prompt = get_slots_menu_prompt(ctx, total_bet) player_input = cinput(menu_prompt).strip() if player_input == "": continue if player_input in "qQ": return "quit" elif player_input in "rR": - if account.balance < bet_amount: + if account.balance < total_bet: continue return "respin" elif player_input in "cC": @@ -224,66 +233,84 @@ def spin_animation( for i in range(5): clear_screen() display_topbar(account, **HEADER_OPTIONS) - print_spin((get_rand_item(), get_rand_item(), get_rand_item()), i) + print_spin(get_rand_grid(), i) time.sleep(sec_btwn_spins) # Animate the slots spinning for _ in range(total_spins): clear_screen() display_topbar(account, **HEADER_OPTIONS) - print_spin((get_rand_item(), get_rand_item(), get_rand_item()), 0) + print_spin(get_rand_grid(), 0) time.sleep(sec_btwn_spins) +def payout_for_row(row: tuple[str, str, str], bet_per_line: int) -> int: + """Return payout for one horizontal payline.""" + a, b, c = row + if not (a == b == c): + return 0 + # High item match + if a in HIGH_ITEMS: + return bet_per_line * 5 + # Low item match + return int(bet_per_line * 1.5) + +def score_lines(grid, line_bets: tuple[int, int, int]) -> tuple[int, list[int]]: + """ Returns the total_payout and winning_rows """ + total = 0 + winners: list[int] = [] + for i, row in enumerate(grid): + bet_for_this_row = line_bets[i] + p = payout_for_row(row, bet_for_this_row) + if p > 0: + total += p + winners.append(i) + return total, winners def play_slots_expanded(ctx: GameContext) -> None: """Play slots game.""" account = ctx.account min_bet = ctx.config.slots_min_line_bet take_new_bet = True - bet_amount = 0 + line_bets: tuple[int, int, int] = (min_bet, min_bet, min_bet) while True: clear_screen() display_topbar(account, **HEADER_OPTIONS) - if take_new_bet or bet_amount > account.balance: - if account.balance < min_bet: + if take_new_bet: + if account.balance < (min_bet): cprint("You don't have enough money to make a bet.\n\n") cinput("Press Enter to continue...") return - bet_amount = get_bet_amount(ctx) + line_bets = get_line_bets(ctx) take_new_bet = False + total_bet = sum(line_bets) + if total_bet > account.balance: + take_new_bet = True + continue + spin_animation(account) clear_screen() display_topbar(account, **HEADER_OPTIONS) # Display final spin result - items: tuple[str, str, str] - rng = random.random() - if rng <= WIN_PROBABILITY: - money_gain = 0 - if rng <= HIGH_VALUE_PROBABILITY: - win_item = HIGH_ITEMS[random.randint(0, len(HIGH_ITEMS) - 1)] - money_gain = bet_amount * 5 - else: - win_item = LOW_ITEMS[random.randint(0, len(LOW_ITEMS) - 1)] - money_gain = int(bet_amount * 1.5) - items = (win_item, win_item, win_item) - account.deposit(money_gain) - clear_screen() - display_topbar(account, **HEADER_OPTIONS) - print_spin(items, 0) - cprint(f"MATCH: +{money_gain} chips") + grid = get_rand_grid() + + account.withdraw(total_bet) + payout, winning_rows = score_lines(grid, line_bets) + if payout > 0: + account.deposit(payout) + + clear_screen() + display_topbar(account, **HEADER_OPTIONS) + print_spin(grid, 0) + + if payout > 0: + rows_str = ",".join(str(r + 1) for r in winning_rows) + cprint(f"ROWS {rows_str} MATCH: -{total_bet} +{payout} chips") else: - items = (get_rand_item(), get_rand_item(), get_rand_item()) - while len(set(items)) == 1: - items = (get_rand_item(), get_rand_item(), get_rand_item()) - clear_screen() - account.withdraw(bet_amount) - display_topbar(account, **HEADER_OPTIONS) - print_spin(items, 0) - cprint(f"NO MATCH: -{bet_amount} chips") + cprint(f"NO MATCH: -{total_bet} chips") # Choose what to do after spin - choice = get_player_choice(ctx, items, bet_amount) + choice = get_player_choice(ctx, grid, total_bet) match choice: case "quit": return