"""Daily lottery: buy tickets, one weighted winner takes the whole pot. Coin flow is conserved without any shared pot record: each ticket's cost is deducted from the buyer at purchase, and at draw time the winner is credited exactly the sum of every ticket's cost (tickets * TICKET_COST). More tickets = higher win chance (weighted draw). Ticket state lives on each user's own record keyed by the draw period, so a full scan is only needed at draw time and for the /lottery pot view - never on the hot path. The period is a draw-date ISO string computed by the caller (Tallinn-time aware); core functions take it explicitly so they stay timezone-agnostic and testable. """ from __future__ import annotations import random from datetime import timedelta from .. import pb_client from ..pb_client import DatabaseError from .store import _commit, _txn, _user_lock, get_user __all__ = [ "TICKET_COST", "MAX_TICKETS_PER_DRAW", "DRAW_HOUR", "period_for", "do_buy_ticket", "do_lottery_draw", "get_lottery_state", ] TICKET_COST = 200 MAX_TICKETS_PER_DRAW = 100 # per-user cap so one whale can't guarantee a win DRAW_HOUR = 21 # Tallinn-time hour the daily draw fires def period_for(now_local) -> str: """Draw-date (ISO) that tickets bought at `now_local` (a tz-aware local datetime) count toward: today before DRAW_HOUR, else tomorrow (today's draw has already fired). The draw loop itself draws for `now_local.date()`.""" d = now_local.date() if now_local.hour >= DRAW_HOUR: d = d + timedelta(days=1) return d.isoformat() async def do_buy_ticket(user_id: int, count: int, period: str) -> dict: """Buy `count` tickets for the draw on `period`. Deducts count*TICKET_COST.""" if count <= 0: return {"ok": False, "reason": "invalid"} async with _user_lock(user_id): try: user = await get_user(user_id) except DatabaseError: return {"ok": False, "reason": "db_error"} if user.get("eco_banned"): return {"ok": False, "reason": "banned"} # A new period resets any tickets left over from a previous draw. held = user.get("lottery_tickets", 0) if user.get("lottery_period") == period else 0 if held + count > MAX_TICKETS_PER_DRAW: return {"ok": False, "reason": "max_tickets", "held": held, "cap": MAX_TICKETS_PER_DRAW} cost = count * TICKET_COST if user["balance"] < cost: return {"ok": False, "reason": "insufficient", "need": cost - user["balance"]} user["balance"] -= cost user["lottery_tickets"] = held + count user["lottery_period"] = period try: await _commit(user_id, user) except DatabaseError: return {"ok": False, "reason": "db_error"} _txn("LOTTERY_BUY", user=user_id, tickets=count, period=period, cost=f"-{cost}", bal=user["balance"]) return { "ok": True, "bought": count, "tickets": user["lottery_tickets"], "cost": cost, "balance": user["balance"], } def _participants(records: list[dict], period: str) -> list[tuple[str, int]]: """(user_id, tickets) for everyone holding tickets for `period`.""" out = [] for r in records: uid = r.get("user_id") if uid and r.get("lottery_period") == period and (r.get("lottery_tickets", 0) or 0) > 0: out.append((uid, int(r["lottery_tickets"]))) return out async def get_lottery_state(period: str, user_id: int | None = None) -> dict: """Pot / participant snapshot for the /lottery view.""" records = await pb_client.list_all_records() parts = _participants(records, period) total_tickets = sum(t for _, t in parts) your_tickets = 0 if user_id is not None: your_tickets = next((t for uid, t in parts if uid == str(user_id)), 0) return { "pot": total_tickets * TICKET_COST, "total_tickets": total_tickets, "participants": len(parts), "your_tickets": your_tickets, "ticket_cost": TICKET_COST, } async def do_lottery_draw(period: str) -> dict | None: """Draw the winner for `period` and credit them the whole pot (minted, since ticket costs were burned at purchase - net conserved). Returns the result, or None if nobody entered.""" records = await pb_client.list_all_records() parts = _participants(records, period) if not parts: return None total_tickets = sum(t for _, t in parts) pot = total_tickets * TICKET_COST winner_id = int(random.choices( [uid for uid, _ in parts], weights=[t for _, t in parts], k=1 )[0]) winner_tickets = next(t for uid, t in parts if uid == str(winner_id)) async with _user_lock(winner_id): try: winner = await get_user(winner_id) except DatabaseError: return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot} winner["balance"] += pot winner["lifetime_earned"] = winner.get("lifetime_earned", 0) + pot winner["biggest_win"] = max(winner.get("biggest_win", 0), pot) winner["peak_balance"] = max(winner.get("peak_balance", 0), winner["balance"]) winner["lottery_tickets"] = 0 # consumed try: await _commit(winner_id, winner) except DatabaseError: return {"ok": False, "reason": "db_error", "winner_id": winner_id, "pot": pot} _txn("LOTTERY_DRAW", winner=winner_id, period=period, pot=f"+{pot}", tickets=winner_tickets, total_tickets=total_tickets, players=len(parts)) return { "ok": True, "winner_id": winner_id, "pot": pot, "winner_tickets": winner_tickets, "total_tickets": total_tickets, "participants": len(parts), "win_chance": winner_tickets / total_tickets, }