| |
| import streamlit as st |
| from dataclasses import dataclass, field |
| from typing import List, Optional, Dict, Literal |
| import random |
| import math |
| import os, time |
| from utils import api as backend |
| from utils import db as dbapi |
|
|
| DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1" |
|
|
| def load_css(file_name: str): |
| try: |
| with open(file_name, "r", encoding="utf-8") as f: |
| st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True) |
| except FileNotFoundError: |
| st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.") |
|
|
|
|
| def _refresh_global_xp(): |
| user = st.session_state.get("user") |
| if not user: |
| return |
| try: |
| stats = backend.user_stats(user["user_id"]) if DISABLE_DB else dbapi.user_xp_and_level(user["user_id"]) |
| st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0)) |
| st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0)) |
| except Exception as e: |
| st.warning(f"XP refresh failed: {e}") |
|
|
|
|
| DD_SCOPE_CLASS = "dd-scope" |
|
|
| def _ensure_dd_css(): |
| """Inject CSS for Debt Dilemma buttons once, scoped under .dd-scope.""" |
| |
| if st.session_state.get("_dd_css_injected"): |
| return |
| st.session_state["_dd_css_injected"] = True |
|
|
| |
| st.markdown(""" |
| <style> |
| .dd-scope .stButton > button { |
| border: none; |
| border-radius: 25px; |
| padding: 0.75rem 1.5rem; |
| font-weight: 700; |
| box-shadow: 0 4px 15px rgba(0,0,0,.2); |
| transition: all .3s ease; |
| } |
| .dd-scope .stButton > button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(0,0,0,.3); |
| } |
| .dd-scope .dd-success .stButton > button { |
| background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); |
| color: #fff; |
| } |
| .dd-scope .dd-warning .stButton > button { |
| background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); |
| color: #000; |
| } |
| .dd-scope .dd-danger .stButton > button { |
| background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%); |
| color: #000; |
| } |
| .dd-scope .dd-neutral .stButton > button { |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| color: #fff; |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| def buttondd(label: str, *, key: str, variant: str = "neutral", **kwargs) -> bool: |
| """ |
| Scoped button wrapper. Use just like st.button but styles are limited to the Debt Dilemma container. |
| |
| Example: |
| buttondd("Pay", key="btn_pay", variant="success", on_click=fn, use_container_width=True) |
| """ |
| _ensure_dd_css() |
| |
| st.markdown(f'<div class="dd-{variant}">', unsafe_allow_html=True) |
| clicked = st.button(label, key=key, **kwargs) |
| st.markdown('</div>', unsafe_allow_html=True) |
| return clicked |
|
|
|
|
| setattr(st, "buttondd", buttondd) |
|
|
|
|
| |
| CURRENCY = "JMD$" |
| MONEY_SCALE = 1000 |
|
|
| def jmd(x: int | float) -> int: |
| """Scale a base unit to JMD integer.""" |
| return int(round(x * MONEY_SCALE)) |
|
|
| def fmt_money(x: int | float) -> str: |
| """Format with thousands separator and currency.""" |
| |
| return f"{CURRENCY}{int(round(x)):,}" |
|
|
| def clamp_money(x: float) -> int: |
| """Round to nearest JMD and never go negative.""" |
| |
| return max(0, int(round(x))) |
|
|
| |
| LATE_FEE_BASE = jmd(10) |
| LATE_FEE_PER_MISS = jmd(5) |
| EMERGENCY_FEE = jmd(25) |
| SMALL_PROC_FEE = jmd(2) |
|
|
| |
| START_WALLET_MIN = 0 |
| START_WALLET_MAX = jmd(10) |
| DISBURSE_LOAN_TO_WALLET = False |
|
|
| |
| CS_EVENT_DECLINE_MIN = 15 |
| CS_EVENT_DECLINE_MAX = 100 |
| CS_EVENT_DECLINE_PER_K = 5 |
| CS_EMERGENCY_EVENT_HIT = 60 |
|
|
| |
| UTILITY_NONPAY_CS_HIT = 25 |
| UTILITY_NONPAY_HAPPY_HIT = 8 |
| UTILITY_RECONNECT_FEE = jmd(2) |
|
|
| |
| |
| |
| @dataclass |
| class LoanDetails: |
| principal: int |
| interestRate: float |
| monthlyPayment: int |
| totalOwed: float |
| monthsPaid: int |
| totalMonths: int |
| missedPayments: int |
| creditScore: int |
|
|
| @dataclass |
| class RandomEvent: |
| id: str |
| title: str |
| description: str |
| icon: str |
| type: Literal['opportunity','expense','penalty','bonus'] |
| impact: Dict[str, int] = field(default_factory=dict) |
| choices: Optional[Dict[str,str]] = None |
|
|
| @dataclass |
| class GameLevel: |
| level: int |
| name: str |
| loanAmount: int |
| interestRate: float |
| monthlyPayment: int |
| totalMonths: int |
| startingIncome: int |
| description: str |
|
|
| |
| |
| |
| GAME_LEVELS: List[GameLevel] = [ |
| GameLevel(1, "🎓 Student Loan", jmd(100), 0.15, jmd(25), 3, jmd(120), "Your first small loan as a student - let's learn together! 📚"), |
| GameLevel(2, "🚗 Car Loan", jmd(250), 0.18, jmd(50), 3, jmd(140), "Buying your first car - bigger responsibility but you've got this! 🌟"), |
| GameLevel(3, "💳 Credit Card Debt", jmd(400), 0.22, jmd(70), 3, jmd(160), "High-interest credit card debt - time to be extra careful! ⚠️"), |
| ] |
|
|
| |
| EXPENSES = [ |
| {"id": "food", "name": "Food", "amount": jmd(0.9), "required": True, "healthImpact": -15, "emoji": "🍎"}, |
| {"id": "transport", "name": "Transport", "amount": jmd(0.5), "required": True, "healthImpact": 0, "emoji": "🚌"}, |
| {"id": "utilities", "name": "Utilities", "amount": jmd(7), "required": True, "healthImpact": -5, "emoji": "💡"}, |
| {"id": "entertainment","name": "Entertainment","amount": jmd(1.5), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🎮"}, |
| {"id": "snacks", "name": "Snacks", "amount": jmd(0.8), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🍿"}, |
| ] |
|
|
| LEVEL_EVENT_POOL = { |
| 1: [ |
| RandomEvent("games_day", "🏟️ School Games Day", "Your school is holding Games Day. Small fee, but huge fun and morale!", "🏟️", "expense", |
| {"wallet": -jmd(2), "happiness": 5}, {"accept": f"Join ({fmt_money(jmd(2))}, +5% happy)", "decline": "Skip"}), |
| RandomEvent("book_fair", "📚 Book Fair", "Discounted textbooks help your grades (and future pay!).", "📚", "opportunity", |
| {"wallet": -jmd(3), "income": jmd(1), "happiness": 3}, {"accept": "Buy books", "decline": "Pass"}), |
| RandomEvent("tuition_deadline", "🎓 Tuition Deadline", "A small admin fee pops up unexpectedly.", "🎓", "expense", |
| {"wallet": -jmd(3.5)}, {"accept": "Pay fee", "decline": "Appeal"}), |
| ], |
| 2: [ |
| RandomEvent("gas_hike", "⛽ Gas Price Hike", "Fuel costs rise this week.", "⛽", "expense", |
| {"wallet": -jmd(2.5)}, {"accept": "Buy gas", "decline": "Drive less"}), |
| RandomEvent("oil_change", "🛠️ Discount Oil Change", "Maintenance now saves larger repair later.", "🛠️", "opportunity", |
| {"wallet": -jmd(3), "creditScore": 5}), |
| ], |
| 3: [ |
| RandomEvent("flash_sale", "🛍️ Flash Sale Temptation", "Limited-time sale! Tempting but watch your debt.", "🛍️", "penalty", |
| {"debt": jmd(4), "happiness": 4}, {"accept": "Buy (+debt)", "decline": "Resist"}), |
| RandomEvent("cashback", "💳 Cashback Bonus", "Your card offers a cashback promo.", "💳", "bonus", |
| {"wallet": jmd(3)}), |
| ], |
| } |
|
|
| EVENT_POOL: List[RandomEvent] = [ |
| |
| RandomEvent("yard_sale", "🧹 Yard Sale Fun!", "You sell old items and make some quick cash! Great job being resourceful! 🌟", "🧹", "opportunity", {"wallet": jmd(6)}), |
| RandomEvent("tutoring", "📚 Tutoring Helper", "You help someone with homework and get paid! Sharing knowledge feels great! 😊", "📚", "opportunity", {"wallet": jmd(5)}), |
| RandomEvent("odd_jobs", "🧰 Weekend Helper", "You mow lawns and wash a car over the weekend! Hard work pays off! 💪", "🧰", "opportunity", {"wallet": jmd(7)}), |
|
|
| |
| RandomEvent("overtime_work", "💼 Extra Work Time", "Your boss offers you overtime this period. Extra money but you'll be tired! 😴", "💼", "opportunity", |
| {"wallet": jmd(8)}, {"accept": f"Work overtime (+{fmt_money(jmd(8))}) 💪", "decline": "Rest instead 😴"}), |
| RandomEvent("freelance_job", "💻 Weekend Project", "A friend asks you to help with their business for some quick cash! 🤝", "💻", "opportunity", |
| {"wallet": jmd(6)}, {"accept": f"Take the job (+{fmt_money(jmd(6))}) 💼", "decline": "Enjoy your weekend 🌈"}), |
| RandomEvent("bonus_payment", "⭐ Amazing Work!", "Your excellent work this period earned you a bonus! You're doing great! 🎉", "⭐", "bonus", {"wallet": jmd(5), "creditScore": 10}), |
| RandomEvent("scholarship_opportunity", "🎓 Learning Reward", "You qualify for a small educational grant! Knowledge pays off! 📖", "🎓", "bonus", {"wallet": jmd(10), "income": jmd(2)}), |
|
|
| |
| RandomEvent("mental_health", "🧠 Feeling Better", "A free counseling session can help you feel better and happier! 🌈", "🧠", "opportunity", |
| {"wallet": 0, "health": 10, "happiness": 10}, {"accept": "Feel better! 😊", "decline": "Maybe later 🤔"}), |
| RandomEvent("health_checkup", "🏥 Health Check", "Local clinic does a free health checkup! Taking care of yourself is important! 💚", "🏥", "opportunity", |
| {"wallet": 0, "health": 10, "happiness": 5}, {"accept": "Get healthy! 💪", "decline": "Skip it 🤷"}), |
|
|
| |
| RandomEvent("landlord_eviction", "🏠 Moving Costs", "You need a small deposit for a new place soon. Moving can be expensive! 📦", "🏠", "expense", |
| {"wallet": -jmd(9)}, {"accept": f"Pay deposit (-{fmt_money(jmd(9))}) 🏠", "decline": "Try to negotiate 🤝"}), |
| RandomEvent("transport_breakdown", "🚫 Transport Trouble", "Your usual transport is down. You need an alternative way to get around! 🚶", "🚫", "expense", |
| {"wallet": -jmd(3)}, {"accept": f"Pay for ride (-{fmt_money(jmd(3))}) 🚗", "decline": "Walk everywhere 🚶"}), |
| RandomEvent("utilities_shutoff", "⚡ Utility Warning", "Utilities will be shut off if not paid soon! Don't let the lights go out! 💡", "⚡", "expense", |
| {"wallet": -jmd(4)}, {"accept": f"Pay now (-{fmt_money(jmd(4))}) 💡", "decline": "Risk it 😬"}), |
| ] |
|
|
| |
| |
| |
| def get_level(level:int) -> GameLevel: |
| return GAME_LEVELS[level-1] |
|
|
| def required_expenses_total() -> int: |
| return sum(e["amount"] for e in EXPENSES if e["required"]) |
|
|
| def progress_percent(total_owed: float, monthly_payment: int, total_months: int) -> float: |
| pct = ((total_months - (total_owed / max(monthly_payment,1))) / total_months) * 100 |
| return max(0.0, min(100.0, pct)) |
|
|
| def payoff_projection(balance: float, apr: float, monthly_payment: int): |
| """ |
| Simulate payoff using the game's timing: |
| - Player pays during the month (before interest). |
| - At month end, interest accrues on the remaining balance and is added. |
| Returns (months_needed, total_interest_paid). If payment <= interest, returns (None, None). |
| """ |
| r = apr / 12.0 |
| if balance <= 0: |
| return 0, 0 |
| if r <= 0: |
| months = math.ceil(balance / max(1, monthly_payment)) |
| return months, 0 |
| if monthly_payment <= balance * r: |
| return None, None |
| months = 0 |
| total_interest = 0.0 |
| b = float(balance) |
| for _ in range(10000): |
| pay = min(monthly_payment, b) |
| b -= pay |
| months += 1 |
| if b <= 1e-6: |
| break |
| interest = b * r |
| b += interest |
| total_interest += interest |
| if monthly_payment <= b * r - 1e-9: |
| return None, None |
| return months, int(round(total_interest)) |
|
|
|
|
| def _award_level_completion_if_needed(): |
| """Give exactly +50 XP once per completed level, including the last level.""" |
| user = st.session_state.get("user") |
| if not user: |
| return |
| lvl = int(st.session_state.currentLevel) |
| key = f"_dd_xp_awarded_L{lvl}" |
| if st.session_state.get(key): |
| return |
|
|
| try: |
| |
| start_ts = st.session_state.get("dd_start_ts", time.time()) |
| elapsed_ms = int(max(0, (time.time() - start_ts) * 1000)) |
|
|
| if DISABLE_DB: |
| |
| backend.record_debt_dilemma_play( |
| user_id=user["user_id"], |
| loans_cleared=1, |
| mistakes=int(st.session_state.loan.missedPayments), |
| elapsed_ms=elapsed_ms, |
| gained_xp=50, |
| ) |
| else: |
| |
| dbapi.record_debt_dilemma_round( |
| user["user_id"], |
| level=lvl, |
| round_no=0, |
| wallet=int(st.session_state.wallet), |
| health=int(st.session_state.health), |
| happiness=int(st.session_state.happiness), |
| credit_score=int(st.session_state.loan.creditScore), |
| event_json={"phase": st.session_state.gamePhase}, |
| outcome=("level_complete" if st.session_state.gamePhase == "level-complete" else "game_complete"), |
| gained_xp=50, |
| elapsed_ms=elapsed_ms, |
| ) |
|
|
| st.session_state[key] = True |
| _refresh_global_xp() |
| st.success("Saved +50 XP for completing this loan") |
| except Exception as e: |
| st.error(f"Could not save completion XP: {e}") |
|
|
|
|
| def check_loan_completion() -> bool: |
| """Advance level or finish game when loan is cleared. Returns True if game phase changed.""" |
| loan = st.session_state.loan |
| |
| if clamp_money(loan.totalOwed) == 0: |
| if st.session_state.currentLevel < len(GAME_LEVELS): |
| st.session_state.gamePhase = "level-complete" |
| st.toast(f"Level {st.session_state.currentLevel} complete! Ready for next?") |
| else: |
| st.session_state.gamePhase = "completed" |
| st.toast("All levels done! 🎉") |
| return True |
| return False |
|
|
| def init_state(): |
| if "gamePhase" not in st.session_state: |
| st.session_state.update({ |
| "gamePhase": "setup", |
| "currentMonth": 1, |
| "currentDay": 1, |
| "daysInMonth": 28, |
| "roundsLeft": 6, |
| "wallet": random.randint(START_WALLET_MIN, START_WALLET_MAX), |
| "monthlyIncome": GAME_LEVELS[0].startingIncome, |
| "health": 100, |
| "happiness": 100, |
| "monthsWithoutFood": 0, |
| "currentEvent": None, |
| "eventHistory": [], |
| "difficultyMultiplier": 1.0, |
| "currentLevel": 1, |
| "paidExpenses": [], |
| "hasWorkedThisMonth": False, |
| "achievements": [], |
| "lastWorkPeriod": 0, |
| "amountPaidThisMonth": 0, |
| "fullPaymentMadeThisMonth": False, |
| "paidFoodToday": False, |
| }) |
| lvl = get_level(1) |
| st.session_state["loan"] = LoanDetails( |
| principal=lvl.loanAmount, |
| interestRate=lvl.interestRate, |
| monthlyPayment=lvl.monthlyPayment, |
| totalOwed=float(lvl.loanAmount), |
| monthsPaid=0, |
| totalMonths=lvl.totalMonths, |
| missedPayments=0, |
| creditScore=random.randint(200, 600), |
| ) |
| if "dd_start_ts" not in st.session_state: |
| st.session_state.dd_start_ts = time.time() |
|
|
| |
| def current_fortnight() -> int: |
| return 1 + (st.session_state.currentDay - 1) // 14 |
|
|
| |
| def check_end_conditions() -> bool: |
| if st.session_state.health <= 0: |
| st.session_state.health = 0 |
| st.session_state.gamePhase = "hospital" |
| st.toast("You've been hospitalized! Health reached 0%. Game Over.") |
| return True |
| if st.session_state.happiness <= 0: |
| st.session_state.happiness = 0 |
| st.session_state.gamePhase = "burnout" |
| st.toast("Happiness reached 0%. You gave up. Game Over.") |
| return True |
| return False |
|
|
| |
| def advance_day(no_event: bool = False): |
| """Advance one day. If no_event=True, skip daily event roll (use when an action already consumed the day).""" |
| if st.session_state.gamePhase == "repaying": |
| if not st.session_state.paidFoodToday: |
| st.session_state.health = max(0, st.session_state.health - 5) |
| st.toast("You skipped food today. Health -5%") |
| st.session_state.paidFoodToday = False |
|
|
| if not no_event and st.session_state.gamePhase == "repaying" and st.session_state.currentEvent is None: |
| new_evt = gen_random_event() |
| if new_evt: |
| st.session_state.currentEvent = new_evt |
| st.toast(f"New event: {new_evt.title}") |
| return |
|
|
| if check_end_conditions(): |
| return |
|
|
| st.session_state.currentDay += 1 |
| if st.session_state.currentDay > st.session_state.daysInMonth: |
| st.session_state.currentDay = 1 |
| next_month() |
| else: |
| st.toast(f"Day {st.session_state.currentDay}/{st.session_state.daysInMonth}") |
|
|
| def fast_forward_to_month_end(): |
| st.toast("Skipping to month end…") |
| st.session_state.currentDay = st.session_state.daysInMonth |
| next_month() |
|
|
| |
| |
| |
| def set_event(evt: Optional[RandomEvent]): |
| st.session_state["currentEvent"] = evt |
|
|
| def gen_random_event() -> Optional[RandomEvent]: |
| currentMonth = st.session_state.currentMonth |
| difficulty = st.session_state.difficultyMultiplier |
| base = 0.08 |
| eventChance = min(base + (currentMonth * 0.03) + (difficulty * 0.02), 0.4) |
| if random.random() < eventChance: |
| seen = set(st.session_state.eventHistory) |
| level_specific = LEVEL_EVENT_POOL.get(st.session_state.currentLevel, []) |
| pool = EVENT_POOL + level_specific |
| available = [e for e in pool if (e.id not in seen) or (e.type in ("opportunity","bonus"))] |
| if available: |
| return random.choice(available) |
| return None |
|
|
| |
| |
| |
| def start_loan(): |
| st.session_state.dd_start_ts = time.time() |
| st.session_state.gamePhase = "repaying" |
| if DISBURSE_LOAN_TO_WALLET: |
| st.session_state.wallet += st.session_state.loan.principal |
| st.toast(f"Loan approved! {fmt_money(st.session_state.loan.principal)} added to your wallet.") |
| else: |
| st.toast("Loan approved! Funds go directly to fees (not your wallet).") |
|
|
| def do_skip_payment(): |
| loan: LoanDetails = st.session_state.loan |
| loan.missedPayments += 1 |
| loan.creditScore = max(300, loan.creditScore - 50) |
| st.toast("Payment missed! Credit score -50.") |
| advance_day(no_event=False) |
|
|
| def can_work_this_period() -> bool: |
| return st.session_state.lastWorkPeriod != current_fortnight() |
|
|
| WORK_HAPPINESS_COST = 10 |
| WORK_MIN = jmd(6) |
| WORK_VAR = jmd(3) |
|
|
| def do_work_for_money(): |
| if not can_work_this_period(): |
| st.toast("You already worked this fortnight. Try later.") |
| return |
| earnings = WORK_MIN + random.randint(0, WORK_VAR) |
| st.session_state.wallet += earnings |
| st.session_state.happiness = max(0, st.session_state.happiness - WORK_HAPPINESS_COST) |
| st.session_state.hasWorkedThisMonth = True |
| st.session_state.lastWorkPeriod = current_fortnight() |
| st.toast(f"Work done! +{fmt_money(earnings)}, Happiness -{WORK_HAPPINESS_COST} (uses 1 day)") |
| if st.session_state.happiness <= 30 and "workaholic" not in st.session_state.achievements: |
| st.session_state.achievements.append("workaholic") |
| st.toast("Achievement: Workaholic - Worked while happiness was low!") |
| if not check_end_conditions(): |
| advance_day(no_event=True) |
|
|
| def do_make_payment_full(): |
| make_payment(st.session_state.loan.monthlyPayment) |
|
|
| def do_make_payment_partial(): |
| |
| leftover = math.ceil(st.session_state.loan.totalOwed) |
| pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), leftover)) |
| make_payment(pay_what) |
|
|
| def make_payment(amount: int): |
| loan: LoanDetails = st.session_state.loan |
| required_total = required_expenses_total() |
| if amount <= 0: |
| st.toast("Enter a valid payment amount.") |
| return |
| if st.session_state.wallet >= amount and st.session_state.wallet - amount >= required_total: |
| st.session_state.wallet -= amount |
| |
| loan.totalOwed = clamp_money(loan.totalOwed - amount) |
| st.session_state.amountPaidThisMonth += amount |
| if amount >= loan.monthlyPayment: |
| loan.monthsPaid += 1 |
| st.session_state.fullPaymentMadeThisMonth = True |
| st.toast(f"Payment successful! {fmt_money(amount)} paid.") |
| else: |
| still = max(0, loan.monthlyPayment - amount) |
| st.toast(f"Partial payment {fmt_money(amount)}. Need {fmt_money(still)} more for full.") |
| |
| if check_loan_completion(): |
| return |
| if st.session_state.gamePhase == "repaying": |
| advance_day(no_event=True) |
| else: |
| st.toast("Not enough money (remember mandatory expenses).") |
|
|
| |
| def pay_expense(expense: Dict): |
| if st.session_state.wallet >= expense["amount"]: |
| st.session_state.wallet -= expense["amount"] |
| if expense["id"] not in st.session_state.paidExpenses: |
| st.session_state.paidExpenses.append(expense["id"]) |
| st.toast(f"Paid {fmt_money(expense['amount'])} for {expense['name']}") |
| boost = int(expense.get("happinessBoost", 0)) |
| if boost: |
| before = st.session_state.happiness |
| st.session_state.happiness = min(100, st.session_state.happiness + boost) |
| st.toast(f"Happiness +{st.session_state.happiness - before}%") |
| if expense["id"] == "food": |
| st.session_state.paidFoodToday = True |
| before_h = st.session_state.health |
| st.session_state.health = min(100, st.session_state.health + 10) |
| healed = st.session_state.health - before_h |
| if healed > 0: |
| st.toast(f"Health +{healed}% from eating well") |
| if expense["id"] == "utilities": |
| before = st.session_state.happiness |
| st.session_state.happiness = max(0, st.session_state.happiness - 3) |
| st.toast(f"Happiness -{before - st.session_state.happiness}% (paid utilities)") |
| check_end_conditions() |
| else: |
| st.toast(f"Can't afford {expense['name']}! It will be auto-deducted at month end.") |
|
|
| |
| def handle_event_choice(accept: bool): |
| evt: Optional[RandomEvent] = st.session_state.currentEvent |
| if not evt: |
| return |
| loan: LoanDetails = st.session_state.loan |
|
|
| if accept and evt.impact: |
| if "wallet" in evt.impact: |
| delta = evt.impact["wallet"] |
| if delta < 0 and st.session_state.wallet < abs(delta): |
| st.toast("You can't afford this! Emergency loan taken.") |
| short = abs(delta) + SMALL_PROC_FEE |
| st.session_state.wallet = 0 |
| |
| loan.totalOwed = clamp_money(loan.totalOwed + short) |
| loan.creditScore = max(300,loan.creditScore - CS_EMERGENCY_EVENT_HIT) |
| st.toast(f"Added to debt: {fmt_money(short)}") |
| else: |
| st.session_state.wallet += delta |
| st.toast(f"{'+' if delta>0 else ''}{fmt_money(delta)} {'earned' if delta>0 else 'spent'}.") |
| if "income" in evt.impact: |
| st.session_state.monthlyIncome = max(jmd(0.05), st.session_state.monthlyIncome + evt.impact["income"]) |
| if "creditScore" in evt.impact: |
| loan.creditScore = max(300, min(850, loan.creditScore + evt.impact["creditScore"])) |
| if "debt" in evt.impact: |
| |
| loan.totalOwed = clamp_money(loan.totalOwed + evt.impact["debt"]) |
| if "health" in evt.impact: |
| st.session_state.health = min(100, max(0, st.session_state.health + evt.impact["health"])) |
| if "happiness" in evt.impact: |
| st.session_state.happiness = min(100, max(0, st.session_state.happiness + evt.impact["happiness"])) |
| elif not accept: |
| if evt.type == "expense": |
| st.toast("You avoided the expense but there might be consequences…") |
| if random.random() < 0.5 and "wallet" in evt.impact: |
| base_k = abs(evt.impact["wallet"]) / MONEY_SCALE |
| penalty = int(round(base_k * CS_EVENT_DECLINE_PER_K)) |
| penalty = max(CS_EVENT_DECLINE_MIN, min(CS_EVENT_DECLINE_MAX, penalty)) |
| loan.creditScore = max(300, loan.creditScore - penalty) |
| st.toast(f"Credit score penalty: -{penalty}") |
| else: |
| st.toast("You declined the opportunity.") |
|
|
| st.session_state.eventHistory.append(evt.id) |
| st.session_state.difficultyMultiplier += 0.1 |
| st.session_state.currentEvent = None |
| if not check_end_conditions(): |
| advance_day(no_event=True) |
|
|
| |
| |
| |
| def check_achievements(): |
| if st.session_state.health == 100 and "perfect-health" not in st.session_state.achievements: |
| st.session_state.achievements.append("perfect-health") |
| st.toast("Achievement: Perfect Health!") |
| if st.session_state.health <= 20 and "survivor" not in st.session_state.achievements: |
| st.session_state.achievements.append("survivor") |
| st.toast("Achievement: Survivor!") |
| if st.session_state.happiness >= 90 and "happy-camper" not in st.session_state.achievements: |
| st.session_state.achievements.append("happy-camper") |
| st.toast("Achievement: Happy Camper!") |
| if st.session_state.wallet <= jmd(0.01) and st.session_state.happiness >= 50 and "broke-not-broken" not in st.session_state.achievements: |
| st.session_state.achievements.append("broke-not-broken") |
| st.toast("Achievement: Broke But Not Broken!") |
| if st.session_state.loan.creditScore >= 800 and "credit-master" not in st.session_state.achievements: |
| st.session_state.achievements.append("credit-master") |
| st.toast("Achievement: Credit Master!") |
|
|
| def next_month(): |
| loan: LoanDetails = st.session_state.loan |
| st.session_state.lastWorkPeriod = 0 |
|
|
| if st.session_state.gamePhase == "repaying": |
| if not st.session_state.fullPaymentMadeThisMonth: |
| loan.missedPayments += 1 |
| st.toast("You missed this month’s full payment.") |
| st.session_state.amountPaidThisMonth = 0 |
| st.session_state.fullPaymentMadeThisMonth = False |
|
|
| unpaid = [e for e in EXPENSES if e["required"] and e["id"] not in st.session_state.paidExpenses] |
| total_forced = sum(e["amount"] for e in unpaid) |
| total_health_loss = sum(abs(e.get("healthImpact", 0)) for e in unpaid) |
|
|
| if total_forced > 0: |
| if st.session_state.wallet >= total_forced: |
| st.session_state.wallet -= total_forced |
| st.session_state.health = max(0, st.session_state.health - total_health_loss) |
| st.toast(f"Mandatory expenses auto-deducted: {fmt_money(total_forced)}, Health -{total_health_loss}") |
| else: |
| shortfall = total_forced - st.session_state.wallet |
| st.session_state.wallet = 0 |
| st.session_state.health = max(0, st.session_state.health - total_health_loss - 10) |
| |
| loan.totalOwed = clamp_money(loan.totalOwed + shortfall + EMERGENCY_FEE) |
| loan.creditScore = max(300, loan.creditScore - 35) |
| st.toast(f"Couldn't afford mandatory expenses! Emergency loan: {fmt_money(shortfall + EMERGENCY_FEE)}, Health -{total_health_loss + 10}") |
|
|
| if st.session_state.currentLevel >= 3 and st.session_state.wallet < st.session_state.monthlyIncome * 0.5: |
| loss = int(((st.session_state.monthlyIncome * 0.5) - st.session_state.wallet) / jmd(1)) |
| if loss > 0: |
| st.session_state.happiness = max(0, st.session_state.happiness - loss) |
| st.toast(f"Low funds affecting mood! Happiness -{loss}") |
|
|
| st.session_state.currentMonth += 1 |
| st.session_state.currentDay = 1 |
| st.session_state.roundsLeft -= 1 |
| st.session_state.wallet += st.session_state.monthlyIncome |
| st.session_state.paidExpenses = [] |
| st.session_state.hasWorkedThisMonth = False |
|
|
| if st.session_state.roundsLeft <= 0: |
| st.toast("Time's up! You ran out of rounds!") |
| st.session_state.gamePhase = "completed" |
| return |
|
|
| if loan.missedPayments > 0: |
| late_fee = LATE_FEE_BASE + (loan.missedPayments * LATE_FEE_PER_MISS) |
| |
| loan.totalOwed = clamp_money(loan.totalOwed + late_fee) |
| st.toast(f"Late fee applied: {fmt_money(late_fee)}") |
| loan.missedPayments = 0 |
|
|
| |
| unpaid_ids = {e["id"] for e in unpaid} |
| if "utilities" in unpaid_ids: |
| |
| loan.creditScore = max(300, loan.creditScore - UTILITY_NONPAY_CS_HIT) |
| st.session_state.happiness = max(0, st.session_state.happiness - UTILITY_NONPAY_HAPPY_HIT) |
| |
| loan.totalOwed = clamp_money(loan.totalOwed + UTILITY_RECONNECT_FEE) |
| st.toast( |
| f"Utilities unpaid: Credit -{UTILITY_NONPAY_CS_HIT}, " |
| f"Happiness -{UTILITY_NONPAY_HAPPY_HIT}, " |
| f"Reconnect fee {fmt_money(UTILITY_RECONNECT_FEE)}" |
| ) |
|
|
| check_achievements() |
|
|
| if st.session_state.gamePhase == "repaying": |
| |
| monthly_interest = clamp_money((loan.totalOwed * loan.interestRate) / 12.0) |
| loan.totalOwed = clamp_money(loan.totalOwed + monthly_interest) |
| |
| check_loan_completion() |
|
|
| st.toast(f"Month {st.session_state.currentMonth}: +{fmt_money(st.session_state.monthlyIncome)} income. {st.session_state.roundsLeft} rounds left.") |
| check_end_conditions() |
|
|
| |
| |
| |
| def header(): |
| level = get_level(st.session_state.currentLevel) |
| base_payday_hint = "Paid at month end" |
| st.markdown(f""" |
| <div class="game-header"> |
| <div class="game-title">🎮 Debt Dilemma 💳</div> |
| <h3>Month {st.session_state.currentMonth} · Day {st.session_state.currentDay}/{st.session_state.daysInMonth}</h3> |
| <p>Level {st.session_state.currentLevel}: {level.name}</p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown(f""" |
| <div class="metric-card"> |
| <h3>📊 Your Status</h3> |
| <div style="display:flex;flex-wrap:wrap;gap:1rem;justify-content:space-between;margin-top:1rem;"> |
| <div><strong>💰 Wallet:</strong> {fmt_money(st.session_state.wallet)}</div> |
| <div><strong>💼 Base Salary:</strong> {fmt_money(st.session_state.monthlyIncome)} <small>({base_payday_hint})</small></div> |
| <div><strong>📊 Credit:</strong> {st.session_state.loan.creditScore}</div> |
| <div><strong>❤️ Health:</strong> {st.session_state.health}%</div> |
| <div><strong>😊 Happy:</strong> {st.session_state.happiness}%</div> |
| </div> |
| <div style="margin-top:.5rem;"> |
| <small>💡 “Work for Money” is extra: once per fortnight you can earn ~JMD$6k–9k for 1 day, and it lowers happiness by 10%.</small> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| def setup_screen(): |
| level = get_level(st.session_state.currentLevel) |
|
|
| |
| st.markdown(f""" |
| <div class="game-header"> |
| <div class="game-title">🎯 Level {level.level}: {level.name}</div> |
| <p>{level.description}</p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| months_est, interest_est = payoff_projection( |
| balance=float(level.loanAmount), |
| apr=level.interestRate, |
| monthly_payment=level.monthlyPayment |
| ) |
| if months_est is None: |
| proj_html = "<small>🧮 Projection: Payment too low — balance will grow.</small>" |
| else: |
| years_est = months_est / 12.0 |
| proj_html = (f"<small>🧮 Projection: ~{months_est} payments (~{years_est:.1f} years), " |
| f"est. interest {fmt_money(interest_est)}</small>") |
|
|
| st.markdown("### 📋 Loan Details") |
| st.markdown(f""" |
| <div class="metric-card"> |
| <h3>💳 Loan Information</h3> |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;"> |
| <div><strong>💰 Amount:</strong> {fmt_money(level.loanAmount)}</div> |
| <div><strong>📈 Interest:</strong> {int(level.interestRate*100)}% yearly</div> |
| <div><strong>💳 Monthly Payment:</strong> {fmt_money(level.monthlyPayment)}</div> |
| <div><strong>⏰ Time Limit (target):</strong> {level.totalMonths} months</div> |
| </div> |
| <div style="margin-top:.5rem;">{proj_html}</div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| st.markdown("### 🌟 Your Current Situation") |
| st.markdown(f""" |
| <div class="metric-card"> |
| <h3>💼 Your Financial Status</h3> |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;"> |
| <div><strong>💰 Wallet (random start):</strong> {fmt_money(st.session_state.wallet)}</div> |
| <div><strong>💼 Base Salary (month end):</strong> {fmt_money(st.session_state.monthlyIncome)}</div> |
| <div><strong>🏠 Required Expenses (per month):</strong> {fmt_money(required_expenses_total())}</div> |
| <div><strong>📊 Credit Score:</strong> {st.session_state.loan.creditScore}</div> |
| </div> |
| <div style="margin-top:.5rem;"> |
| <small>💡 You get your <strong>base salary automatically at month end</strong>. The <em>Work for Money</em> button gives <strong>extra</strong> cash (~JMD$6k–9k) and can be used <strong>once per fortnight</strong> (costs 1 day, -10% happiness).</small> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| st.markdown(""" |
| ### 🎮 Game Rules - Let's Learn Together! |
| 🎯 **Your Mission:** Pay off your loan while staying healthy and happy! |
| |
| 📚 **Important Rules:** |
| - 💰 Interest grows your debt each month - pay on time! |
| - ❤️ Health 0% = hospital visit (game over!) |
| - 😊 Happiness 0% = you give up (game over!) |
| - 🍎 Food keeps you healthy (+10 health when paid!) |
| - 🎮 Entertainment & 🍿 Snacks make you happy (+5% each!) |
| - 🎲 Random events happen daily - some good, some challenging! |
| |
| ⏰ **Time Costs:** |
| - 💼 Work (extra) = 1 day (**once per fortnight**) |
| - 💳 Make loan payment = 1 day |
| - 🎲 Handle events = 1 day |
| - 🏠 Paying expenses = FREE (no time cost!) |
| |
| 💡 **Payday:** Your **base salary** hits your wallet automatically at **month end**. |
| """) |
|
|
| |
| st.buttondd( |
| f"🚀 Accept Level {level.level} Loan & {'Receive ' + fmt_money(level.loanAmount) if DISBURSE_LOAN_TO_WALLET else 'Start the Level'}!", |
| use_container_width=True, |
| on_click=start_loan, |
| key="btn_start_loan", |
| variant="success" |
| ) |
|
|
| def main_screen(): |
| header() |
|
|
| left, right = st.columns([2,1]) |
|
|
| with left: |
| evt: Optional[RandomEvent] = st.session_state.currentEvent |
| if evt: |
| st.markdown(f""" |
| <div class="event-card"> |
| <div class="event-title">{evt.icon} {evt.title}</div> |
| <p>{evt.description}</p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| badge_colors = { |
| "opportunity": "🌟 GREAT OPPORTUNITY!", |
| "expense": "⚠️ EXPENSE ALERT", |
| "penalty": "⛔ CHALLENGE", |
| "bonus": "🎁 AWESOME BONUS!" |
| } |
| st.success(badge_colors[evt.type]) |
|
|
| c1, c2 = st.columns(2) |
| if evt.choices: |
| with c1: |
| st.buttondd(evt.choices["accept"], use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_accept", variant="success") |
| with c2: |
| st.buttondd(evt.choices["decline"], use_container_width=True, on_click=lambda: handle_event_choice(False), key="evt_decline", variant="warning") |
| else: |
| st.buttondd("✨ Continue (uses 1 day)", use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_continue", variant="success") |
|
|
| st.markdown("---") |
|
|
| |
| progress = progress_percent(st.session_state.loan.totalOwed, st.session_state.loan.monthlyPayment, st.session_state.loan.totalMonths)/100 |
| months_est, interest_est = payoff_projection( |
| st.session_state.loan.totalOwed, |
| st.session_state.loan.interestRate, |
| st.session_state.loan.monthlyPayment |
| ) |
| if months_est is None: |
| proj_html = "<div><strong>🧮 Projection:</strong> Payment too low — balance will grow.</div>" |
| else: |
| years_est = months_est / 12.0 |
| proj_html = ( |
| f"<div><strong>🧮 Projection:</strong> ~{months_est} payments " |
| f"(~{years_est:.1f} years), est. interest {fmt_money(interest_est)}</div>" |
| ) |
|
|
| st.markdown(f""" |
| <div class="metric-card"> |
| <h3>💳 Loan Status</h3> |
| <div style="margin-top: 1rem;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;"> |
| <div><strong>💰 Still Owed:</strong> {fmt_money(st.session_state.loan.totalOwed)}</div> |
| <div><strong>💳 Monthly Due:</strong> {fmt_money(st.session_state.loan.monthlyPayment)}</div> |
| </div> |
| {proj_html} |
| <div style="margin: 1rem 0;"> |
| <div style="background: #e0e0e0; border-radius: 10px; height: 20px; overflow: hidden;"> |
| <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); height: 100%; width: {progress*100}%; border-radius: 10px;"></div> |
| </div> |
| <div style="text-align: center; margin-top: 0.5rem;"><strong>🎯 Progress: {progress*100:.1f}% Complete!</strong></div> |
| </div> |
| <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem;"> |
| <div><strong>✅ Payments:</strong> {st.session_state.loan.monthsPaid}/{st.session_state.loan.totalMonths}</div> |
| <div><strong>{"⚠️" if st.session_state.loan.missedPayments > 0 else "✅"} Missed:</strong> {st.session_state.loan.missedPayments}</div> |
| <div><small>{(st.session_state.loan.interestRate*100/12):.1f}% monthly interest</small></div> |
| <div><small>📅 Paid This Month: {fmt_money(st.session_state.amountPaidThisMonth)}{" — ✅ Full" if st.session_state.fullPaymentMadeThisMonth else ""}</small></div> |
| </div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| can_afford = st.session_state.wallet >= (st.session_state.loan.monthlyPayment + required_expenses_total()) |
| |
| pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), math.ceil(st.session_state.loan.totalOwed))) |
|
|
| b1, b2, b3 = st.columns([2,2,2]) |
| with b1: |
| st.buttondd( |
| f"💰 Full Payment (1 day) {fmt_money(st.session_state.loan.monthlyPayment)}", |
| disabled=not can_afford, |
| use_container_width=True, |
| on_click=do_make_payment_full, |
| key="btn_pay_full", |
| variant="success" if can_afford else "warning" |
| ) |
| with b2: |
| st.buttondd( |
| f"💸 Pay What I Can (1 day) {fmt_money(pay_what)}", |
| disabled=pay_what<=0, |
| use_container_width=True, |
| on_click=do_make_payment_partial, |
| key="btn_pay_partial", |
| variant="success" if pay_what>0 else "warning" |
| ) |
| with b3: |
| st.buttondd("⏭️ Skip Payment (1 day)", use_container_width=True, on_click=do_skip_payment, key="btn_skip", variant="danger") |
|
|
| st.markdown("### 🏠 Monthly Expenses (Free Actions - No Time Cost!)") |
| cols = st.columns(2) |
| for i, exp in enumerate(EXPENSES): |
| with cols[i % 2]: |
| required_text = "⚠️ Required" if exp["required"] else "🌟 Optional" |
| happiness_text = f"<br><small>😊 (+{exp.get('happinessBoost', 0)}% happiness)</small>" if exp.get('happinessBoost', 0) > 0 else "" |
| st.markdown(f""" |
| <div class="expense-card"> |
| <h4>{exp['emoji']} {exp['name']} - {fmt_money(exp['amount'])}</h4> |
| <p>{required_text}{happiness_text}</p> |
| </div> |
| """, unsafe_allow_html=True) |
| disabled = st.session_state.wallet < exp["amount"] |
| st.buttondd( |
| f"{exp['emoji']} Pay", |
| key=f"pay_{exp['id']}", |
| disabled=disabled, |
| on_click=lambda e=exp: pay_expense(e), |
| use_container_width=True, |
| variant="success" if not disabled else "warning" |
| ) |
|
|
| st.markdown("---") |
| label = "🌅 End Day & See What Happens!" |
| if st.session_state.currentDay == st.session_state.daysInMonth: |
| label = f"🗓️ End Month {st.session_state.currentMonth} → Payday: {fmt_money(st.session_state.monthlyIncome)}!" |
| st.buttondd(label, disabled=st.session_state.currentEvent is not None, use_container_width=True, on_click=lambda: advance_day(no_event=False), key="btn_end_day", variant="success") |
| st.buttondd("⏩ Skip to Month End (When Low on Money!)", use_container_width=True, on_click=fast_forward_to_month_end, key="btn_ff", variant="warning") |
|
|
| with right: |
| health_color = "🟢" if st.session_state.health > 70 else "🟡" if st.session_state.health > 30 else "🔴" |
| happiness_color = "😊" if st.session_state.happiness > 70 else "😐" if st.session_state.happiness > 30 else "😢" |
|
|
| net_monthly = st.session_state.monthlyIncome - required_expenses_total() - st.session_state.loan.monthlyPayment |
| net_color = "🟢" if net_monthly > 0 else "🔴" |
|
|
| st.markdown("### 🌟 Your Wellbeing") |
| st.markdown(f""" |
| <div class="metric-card"> |
| <h3>💪 Status Overview</h3> |
| <div style="margin-top: 1rem;"> |
| <div style="display: flex; justify-content: space-between; margin-bottom: 1rem;"> |
| <div><strong>❤️ Health:</strong> {health_color} {st.session_state.health}%</div> |
| <div><strong>😊 Happiness:</strong> {happiness_color} {st.session_state.happiness}%</div> |
| </div> |
| <div style="text-align: center; padding: 1rem 0; border-top: 1px solid #eee;"> |
| <strong>💹 Monthly Budget:</strong> {net_color} {fmt_money(net_monthly)}<br> |
| <small>After loan & required expenses</small> |
| </div> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| work_available = can_work_this_period() |
| st.buttondd("💼 Work for Money! (1 day, once/fortnight)\n~JMD$6k–9k, -10% Happiness", |
| disabled=not work_available, |
| on_click=do_work_for_money, |
| key="btn_work", |
| variant="success" if work_available else "warning") |
| cur_fn = current_fortnight() |
| st.caption(f"📅 Fortnight {cur_fn}/2 — you can work once each 2 weeks!") |
|
|
| def reset_game(): |
| |
| for k in list(st.session_state.keys()): |
| |
| if k not in {"user", "current_page", "xp", "streak", "current_game", "temp_user"}: |
| del st.session_state[k] |
| init_state() |
| st.session_state.dd_start_ts = time.time() |
| st.rerun() |
|
|
| def hospital_screen(): |
| st.error("🏥 You've been hospitalized. Health hit 0%. Game over.") |
| |
| st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_hospital", variant="success") |
|
|
| def burnout_screen(): |
| st.warning("😵 You burned out. Happiness hit 0%. Game over.") |
| |
| st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_burnout", variant="success") |
|
|
| def level_complete_screen(): |
| _award_level_completion_if_needed() |
| st.success(f"🎉 Level {st.session_state.currentLevel} complete!") |
| def _go_next(): |
| st.session_state.currentLevel += 1 |
| lvl = get_level(st.session_state.currentLevel) |
| st.session_state.loan = LoanDetails( |
| principal=lvl.loanAmount, |
| interestRate=lvl.interestRate, |
| monthlyPayment=lvl.monthlyPayment, |
| totalOwed=float(lvl.loanAmount), |
| monthsPaid=0, |
| totalMonths=lvl.totalMonths, |
| missedPayments=0, |
| creditScore=st.session_state.loan.creditScore, |
| ) |
| st.session_state.monthlyIncome = lvl.startingIncome |
| st.session_state.dd_start_ts = time.time() |
| st.session_state.gamePhase = "setup" |
| st.buttondd("➡️ Start next level", on_click=_go_next, key="btn_next_level", variant="success") |
|
|
| def completed_screen(): |
| _award_level_completion_if_needed() |
| st.balloons() |
| st.success("🏁 You’ve finished all levels or ran out of rounds!") |
| |
| st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_completed", variant="success") |
|
|
| |
| |
| |
| def show_debt_dilemma(): |
| |
| load_css(os.path.join("assets", "styles.css")) |
| |
| _ensure_dd_css() |
| st.markdown(f'<div class="{DD_SCOPE_CLASS}">', unsafe_allow_html=True) |
|
|
| |
| init_state() |
|
|
| |
| phase = st.session_state.gamePhase |
| if phase == "setup": |
| setup_screen() |
| elif phase == "hospital": |
| hospital_screen() |
| elif phase == "burnout": |
| burnout_screen() |
| elif phase == "level-complete": |
| level_complete_screen() |
| elif phase == "completed": |
| completed_screen() |
| else: |
| main_screen() |
|
|
| st.markdown('</div>', unsafe_allow_html=True) |
|
|