""" core/odds.py ------------ Mathematical prize-odds calculator using combinatorics. Supports games with 0 or 1 bonus ball; bonus_count >= 2 returns jackpot only. """ import math from db.models import get_game_by_id def _comb(n: int, r: int) -> int: if r < 0 or r > n: return 0 return math.comb(n, r) def total_combinations(main_count: int, main_max: int, bonus_count: int, bonus_max: int) -> int: """Total distinct tickets for the given game parameters.""" main = _comb(main_max, main_count) if bonus_count == 0: return main if bonus_count == 1: return main * bonus_max return main * _comb(bonus_max, bonus_count) def _tier_ways(main_count: int, main_max: int, bonus_count: int, bonus_max: int, req_main: int, req_bonus: bool) -> int: """Tickets matching exactly req_main main balls and the given bonus state.""" main_ways = _comb(main_count, req_main) * _comb(main_max - main_count, main_count - req_main) if bonus_count == 0: return main_ways if not req_bonus else 0 return main_ways * (1 if req_bonus else bonus_max - 1) def _fmt_odds(odds: float) -> str: rounded = round(odds) if abs(odds - rounded) < 0.01: return f"1 in {rounded:,}" return f"1 in {odds:,.2f}" def prize_odds(main_count: int, main_max: int, bonus_count: int, bonus_max: int) -> list[dict]: """ Returns a list of dicts for each prize tier: {tier, ways, total, odds, odds_str, probability} Tiers are generated dynamically; bonus_count > 1 returns jackpot only. """ total = total_combinations(main_count, main_max, bonus_count, bonus_max) if bonus_count > 1: return [{"tier": "Jackpot", "ways": 1, "total": total, "odds": float(total), "odds_str": _fmt_odds(float(total)), "probability": 1.0 / total}] rows = [] for k in range(main_count, -1, -1): tier_defs = [] if bonus_count == 1: if k == main_count: label = "Jackpot" elif k > 0: label = f"Match {k} + Bonus" else: label = "Bonus Only" tier_defs.append((k, True, label)) if bonus_count == 0 and k == main_count: tier_defs.append((k, False, "Jackpot")) elif k > 0: tier_defs.append((k, False, f"Match {k}")) # k == 0 no-bonus → No Prize, skip for req_main, req_bonus, label in tier_defs: ways = _tier_ways(main_count, main_max, bonus_count, bonus_max, req_main, req_bonus) if ways <= 0: continue odds = total / ways rows.append({ "tier": label, "ways": ways, "total": total, "odds": odds, "odds_str": _fmt_odds(odds), "probability": ways / total, }) rows.sort(key=lambda r: r["odds"], reverse=True) return rows def game_odds(game_id: int) -> list[dict]: """Compute prize odds for a game looked up by ID.""" game = get_game_by_id(game_id) if game is None: return [] return prize_odds( game["main_count"], game["main_max"], game["bonus_count"], game["bonus_max"], )