From d1cf6cdbd1f86a8909c5eab111e957a52ab95bfc Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 03:10:42 +0300 Subject: [PATCH] feat(economy): add /lottery - daily draw, weighted winner takes the pot Buy tickets (200 coins each, max 100/draw); one winner is drawn daily at 21:00 Tallinn time weighted by ticket count and credited the whole pot. Coin-conserving by design: each ticket's cost is deducted at purchase and the winner is minted exactly the sum of all ticket spend - no shared pot record, so no cross-period race. Ticket state lives per-user keyed by draw period (full scan only at draw time and for the pot view). - New lottery.py: TICKET_COST/MAX_TICKETS/DRAW_HOUR, pure period_for, and do_buy_ticket / get_lottery_state / do_lottery_draw. New lottery_tickets + lottery_period schema fields (period added to _TEXT_FIELDS). - /lottery [kogus] command (view or buy) with full failure handling. - Scheduled lottery_draw_daily loop in bot.py (both profiles; each draws its own collection), announcing to the optional LOTTERY_CHANNEL_ID (config + .env). 14 tests: period boundary, buy/accumulate/reset/caps/guards, pot state, draw payout with coin conservation, and the more-tickets-wins-more weighting. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT --- .env.example | 6 ++ README.md | 1 + bot.py | 71 ++++++++++++++ commands/economy_extra_commands.py | 61 ++++++++++++ config.py | 9 ++ core/economy/__init__.py | 5 +- core/economy/lottery.py | 149 +++++++++++++++++++++++++++++ core/economy/store.py | 4 + scripts/sync_pb_schema.py | 2 +- strings/__init__.py | 2 + strings/commands.py | 3 + strings/economy.py | 21 ++++ tests/test_lottery.py | 131 +++++++++++++++++++++++++ 13 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 core/economy/lottery.py create mode 100644 tests/test_lottery.py diff --git a/.env.example b/.env.example index ca588c6..485bd09 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,12 @@ BIRTHDAY_CHANNEL_ID= # How many days before a birthday the on-join check counts as "coming up" BIRTHDAY_WINDOW_DAYS=7 +# Channel ID where the daily lottery draw result is announced (optional - the +# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL) +LOTTERY_CHANNEL_ID_DEV= +LOTTERY_CHANNEL_ID_ECONOMY= +LOTTERY_CHANNEL_ID= + # PocketBase backend (https://pocketbase.io) PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com diff --git a/README.md b/README.md index 2ecae2b..c82c85c 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e | `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. | | `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. | | `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. | +| `/lottery [amount]` | View the pot or buy tickets (200 ⬡ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. | | `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). | | `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. | | `/fishsell` | Sell all fish currently in your inventory at once. | diff --git a/bot.py b/bot.py index bbaae03..98f0174 100644 --- a/bot.py +++ b/bot.py @@ -336,6 +336,72 @@ async def before_birthday_daily(): await bot.wait_until_ready() +# --------------------------------------------------------------------------- +# Daily lottery draw (Tallinn-time DRAW_HOUR:00) +# --------------------------------------------------------------------------- +async def _resolve_channel(channel_id: int): + """Best-effort fetch of a text channel by id (cache, then API).""" + if not channel_id: + return None + channel = bot.get_channel(channel_id) + if channel is None: + try: + channel = await bot.fetch_channel(channel_id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + return None + return channel + + +@tasks.loop(time=datetime.time(hour=economy.lottery.DRAW_HOUR, minute=0, tzinfo=TALLINN_TZ)) +async def lottery_draw_daily(): + """Draw the day's lottery winner and announce it (if a channel is set).""" + period = datetime.datetime.now(TALLINN_TZ).date().isoformat() + try: + result = await economy.do_lottery_draw(period) + except Exception: + log.exception("Lottery draw failed for %s", period) + return + channel = await _resolve_channel(config.LOTTERY_CHANNEL_ID) + if result is None: + log.info("Lottery draw %s: no participants", period) + if channel: + try: + await channel.send(S.LOTTERY_UI["draw_none"]) + except discord.HTTPException: + pass + return + if not result.get("ok"): + log.error("Lottery draw %s could not pay winner %s (pot %s)", + period, result.get("winner_id"), result.get("pot")) + return + log.info("Lottery draw %s: winner %s won %s (%s/%s tickets)", + period, result["winner_id"], result["pot"], + result["winner_tickets"], result["total_tickets"]) + if channel: + mention = f"<@{result['winner_id']}>" + embed = discord.Embed( + title=S.LOTTERY_UI["draw_title"], + description=S.LOTTERY_UI["draw_win"].format( + winner=mention, + pot=_coin(result["pot"]), + tickets=result["winner_tickets"], + total=result["total_tickets"], + chance=round(result["win_chance"] * 100, 1), + players=result["participants"], + ), + color=0xF4C430, + ) + try: + await channel.send(content=mention, embed=embed) + except discord.HTTPException: + pass + + +@lottery_draw_daily.before_loop +async def before_lottery_draw_daily(): + await bot.wait_until_ready() + + # --------------------------------------------------------------------------- # Rotating rich presence # --------------------------------------------------------------------------- @@ -433,6 +499,11 @@ async def on_ready(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") + # Start daily lottery draw (runs in every profile; each has its own collection) + if not lottery_draw_daily.is_running(): + lottery_draw_daily.start() + log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR) + # Start rotating rich presence if not _rotate_presence.is_running(): _rotate_presence.start() diff --git a/commands/economy_extra_commands.py b/commands/economy_extra_commands.py index c846ef4..1101339 100644 --- a/commands/economy_extra_commands.py +++ b/commands/economy_extra_commands.py @@ -5,6 +5,7 @@ import datetime import random import time from collections.abc import Awaitable, Callable, MutableSet +from zoneinfo import ZoneInfo import discord from discord import app_commands @@ -15,6 +16,8 @@ import strings as S from ._replies import reply_db_error +_TALLINN = ZoneInfo("Europe/Tallinn") + def register_economy_extra_commands( tree: app_commands.CommandTree, @@ -554,6 +557,64 @@ def register_economy_extra_commands( ) await interaction.response.send_message(embed=embed) + # ----------------------------------------------------------------------- + # /lottery - daily draw, one weighted winner takes the pot + # ----------------------------------------------------------------------- + @tree.command(name="lottery", description=S.CMD["lottery"]) + @app_commands.describe(kogus=S.OPT["lottery_kogus"]) + async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None): + period = economy.period_for(datetime.datetime.now(_TALLINN)) + + if kogus is None: + state = await economy.get_lottery_state(period, interaction.user.id) + embed = discord.Embed( + title=S.LOTTERY_UI["title"], + description=S.LOTTERY_UI["desc"].format( + draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"]) + ), + color=0xF4C430, + ) + embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True) + embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True) + if state["your_tickets"]: + chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1) + embed.add_field( + name=S.LOTTERY_UI["f_your"], + value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance), + inline=False, + ) + else: + embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False) + await interaction.response.send_message(embed=embed) + return + + if kogus <= 0: + await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) + return + res = await economy.do_buy_ticket(interaction.user.id, kogus, period) + if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + elif res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "max_tickets": + await interaction.response.send_message( + S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), ephemeral=True + ) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) + return + await interaction.response.send_message( + S.LOTTERY_UI["bought"].format( + count=res["bought"], cost=coin(res["cost"]), + tickets=res["tickets"], balance=coin(res["balance"]), + ) + ) + class LeaderboardView(discord.ui.View): PER_PAGE = 10 diff --git a/config.py b/config.py index be14bd8..89a2cb2 100644 --- a/config.py +++ b/config.py @@ -42,6 +42,15 @@ BIRTHDAY_CHANNEL_ID = ( BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] +# Channel where the daily lottery draw result is announced. Optional - if unset, +# the draw still runs and pays the winner, it just isn't announced. +_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0) +LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID) +LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0) +LOTTERY_CHANNEL_ID = ( + LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV +) + def _parse_admin_roles(raw: str) -> dict[int, set[int]]: """Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...". diff --git a/core/economy/__init__.py b/core/economy/__init__.py index 73f5b71..05275ec 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -32,10 +32,11 @@ from .prestige import * from .leaderboards import * from .heist import * from .achievements import * +from .lottery import * from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) achievements, admin, bank, consumables, fishing, gambling, heist, house, - income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store, - vanity, + income, jail, leaderboards, levels, lootbox, lottery, prestige, quests, + shop, store, vanity, ) diff --git a/core/economy/lottery.py b/core/economy/lottery.py new file mode 100644 index 0000000..84f9213 --- /dev/null +++ b/core/economy/lottery.py @@ -0,0 +1,149 @@ +"""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, + } diff --git a/core/economy/store.py b/core/economy/store.py index be07226..689f561 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -155,6 +155,8 @@ class UserData(TypedDict, total=False): best_daily_streak: int lootboxes_opened: int achievements_earned: list # ids of achievements already claimed + lottery_tickets: int # tickets held for the current lottery period + lottery_period: str | None # draw-date the held tickets count for (ISO date) heist_global_cd_until: float # Prestige system prestige_level: int @@ -219,6 +221,8 @@ def _default_user() -> UserData: "best_daily_streak": 0, "lootboxes_opened": 0, "achievements_earned": [], + "lottery_tickets": 0, + "lottery_period": None, "heist_global_cd_until": 0.0, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, diff --git a/scripts/sync_pb_schema.py b/scripts/sync_pb_schema.py index 6e31cdf..e4c2c14 100644 --- a/scripts/sync_pb_schema.py +++ b/scripts/sync_pb_schema.py @@ -39,7 +39,7 @@ COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLEC _TEXT_FIELDS = { "last_daily", "last_work", "last_beg", "last_crime", "last_rob", "last_heist", "last_fish", "last_streak_date", "jailed_until", - "vanity_active", + "vanity_active", "lottery_period", } diff --git a/strings/__init__.py b/strings/__init__.py index 1498f26..0315bb4 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -62,6 +62,7 @@ from .economy import ( LOOTBOX_UI, BANK_UI, ACHIEVEMENTS_UI, + LOTTERY_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -158,6 +159,7 @@ __all__ = [ 'LOOTBOX_UI', 'BANK_UI', 'ACHIEVEMENTS_UI', + 'LOTTERY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index ea4a04e..83b68c5 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -80,6 +80,7 @@ CMD: dict[str, str] = { "bank": "Vaata oma panka - röövikindel hoius", "deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)", "withdraw": "Võta münte pangast rahakotti", + "lottery": "Vaata TipiLOTO potti või osta pileteid (tühjaks jättes näeb infot)", } # --------------------------------------------------------------------------- @@ -99,6 +100,7 @@ OPT: dict[str, str] = { "buy_ese": "Eseme nimi (vaata /shop)", "deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)", "withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)", + "lottery_kogus": "Mitu piletit osta (tühjaks jättes näeb potti)", "consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)", "vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", @@ -161,6 +163,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("/withdraw ", "Võta münte pangast rahakotti."), ("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."), ("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."), + ("/lottery [kogus]", "Vaata TipiLOTO potti või osta pileteid. Loosimine iga päev - üks võitja saab kogu poti (rohkem pileteid = suurem võiduvõimalus)."), ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), ("/buy ", "Osta ese TipiBOTi poodist"), diff --git a/strings/economy.py b/strings/economy.py index 2f21233..14f5e7a 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -21,6 +21,7 @@ __all__ = [ 'LOOTBOX_UI', 'BANK_UI', 'ACHIEVEMENTS_UI', + 'LOTTERY_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -302,6 +303,26 @@ ACHIEVEMENTS_UI: dict[str, str] = { "unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}", } +# --------------------------------------------------------------------------- +# Lottery (daily draw; one weighted winner takes the pot) +# --------------------------------------------------------------------------- +LOTTERY_UI: dict[str, str] = { + "title": "🎟️ TipiLOTO", + "desc": "Osta pileteid ja võida kogu pott! Loosimine iga päev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem võiduvõimalus.", + "f_pot": "💰 Praegune pott", + "f_players": "👥 Osalejaid", + "f_your": "🎟️ Sinu piletid", + "your_val": "{tickets} tk · võiduvõimalus **{chance}%**", + "your_none": "Sul pole veel pileteid. Osta käsuga `/lottery `.", + "bought": "🎟️ Ostsid **{count}** piletit ({cost}).\nSul on nüüd **{tickets}** piletit selle päeva loosimises.\nSaldo: {balance}", + "max_tickets": "❌ Maksimaalne piletite arv ühes loosimises on {cap} (sul on {held}).", + "empty_pot": "🎟️ Pott on tühi - ole esimene, kes piletit ostab!", + # Draw announcement + "draw_title": "🎟️ TipiLOTO loosimine!", + "draw_win": "🎉 Võitja: {winner}\n💰 Võit: **{pot}**\n🎟️ {tickets}/{total} piletit ({chance}%)\n👥 {players} osalejat", + "draw_none": "🎟️ Täna keegi pileteid ei ostnud - loosimist ei toimunud.", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "Kõik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_lottery.py b/tests/test_lottery.py new file mode 100644 index 0000000..535e8a6 --- /dev/null +++ b/tests/test_lottery.py @@ -0,0 +1,131 @@ +"""Tests for the daily lottery (buy tickets, weighted winner takes the pot).""" + +import datetime +import random +from zoneinfo import ZoneInfo + +from core import economy + +from conftest import run + +TZ = ZoneInfo("Europe/Tallinn") +P = "2026-09-04" # a fixed draw period +UID = 3131 +UID2 = 3132 +UID3 = 3133 + + +def _fund(fake_pb, uid: int, balance: int) -> None: + run(economy.get_user(uid)) + fake_pb.record_for(uid)["balance"] = balance + + +class TestPeriod: + def test_before_draw_hour_is_today(self): + dt = datetime.datetime(2026, 9, 4, 20, 0, tzinfo=TZ) + assert economy.period_for(dt) == "2026-09-04" + + def test_at_or_after_draw_hour_is_tomorrow(self): + dt = datetime.datetime(2026, 9, 4, economy.DRAW_HOUR, 0, tzinfo=TZ) + assert economy.period_for(dt) == "2026-09-05" + + +class TestBuy: + def test_buy_deducts_and_records_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + res = run(economy.do_buy_ticket(UID, 3, P)) + assert res["ok"] and res["tickets"] == 3 + assert res["cost"] == 3 * economy.TICKET_COST + assert res["balance"] == 10_000 - 3 * economy.TICKET_COST + rec = fake_pb.record_for(UID) + assert rec["lottery_tickets"] == 3 and rec["lottery_period"] == P + + def test_buy_accumulates_same_period(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 2, P)) + res = run(economy.do_buy_ticket(UID, 3, P)) + assert res["tickets"] == 5 + + def test_new_period_resets_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 5, P)) + res = run(economy.do_buy_ticket(UID, 1, "2026-09-05")) + assert res["tickets"] == 1 # old period's tickets dropped + + def test_insufficient_rejected(self, fake_pb): + _fund(fake_pb, UID, 100) + res = run(economy.do_buy_ticket(UID, 1, P)) + assert not res["ok"] and res["reason"] == "insufficient" + assert fake_pb.record_for(UID)["balance"] == 100 + + def test_max_tickets_enforced(self, fake_pb): + _fund(fake_pb, UID, 10_000_000) + res = run(economy.do_buy_ticket(UID, economy.MAX_TICKETS_PER_DRAW + 1, P)) + assert not res["ok"] and res["reason"] == "max_tickets" + + def test_nonpositive_rejected(self, fake_pb): + _fund(fake_pb, UID, 10_000) + assert run(economy.do_buy_ticket(UID, 0, P))["reason"] == "invalid" + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, UID, 10_000) + fake_pb.record_for(UID)["eco_banned"] = True + assert run(economy.do_buy_ticket(UID, 1, P))["reason"] == "banned" + + +class TestState: + def test_pot_and_your_tickets(self, fake_pb): + _fund(fake_pb, UID, 10_000) + _fund(fake_pb, UID2, 10_000) + run(economy.do_buy_ticket(UID, 3, P)) + run(economy.do_buy_ticket(UID2, 2, P)) + state = run(economy.get_lottery_state(P, UID)) + assert state["total_tickets"] == 5 + assert state["pot"] == 5 * economy.TICKET_COST + assert state["participants"] == 2 + assert state["your_tickets"] == 3 + + def test_other_period_not_counted(self, fake_pb): + _fund(fake_pb, UID, 10_000) + run(economy.do_buy_ticket(UID, 3, "2026-01-01")) + state = run(economy.get_lottery_state(P)) + assert state["total_tickets"] == 0 and state["pot"] == 0 + + +class TestDraw: + def test_no_participants_returns_none(self, fake_pb): + assert run(economy.do_lottery_draw(P)) is None + + def test_winner_gets_whole_pot_and_coins_conserved(self, fake_pb): + _fund(fake_pb, UID, 10_000) + _fund(fake_pb, UID2, 10_000) + run(economy.do_buy_ticket(UID, 3, P)) # -600 + run(economy.do_buy_ticket(UID2, 2, P)) # -400 + pot = 5 * economy.TICKET_COST + total_before = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2)) + + random.seed(1) + res = run(economy.do_lottery_draw(P)) + assert res["ok"] and res["pot"] == pot + winner, loser = (UID, UID2) if res["winner_id"] == UID else (UID2, UID) + assert fake_pb.record_for(winner)["balance"] == ( + (10_000 - (3 if winner == UID else 2) * economy.TICKET_COST) + pot + ) + # Coins conserved: the pot minted to the winner equals total ticket spend. + total_after = sum(fake_pb.record_for(u)["balance"] for u in (UID, UID2)) + assert total_after == total_before + pot + assert fake_pb.record_for(res["winner_id"])["lottery_tickets"] == 0 # consumed + + def test_more_tickets_wins_more_often(self, fake_pb): + _fund(fake_pb, UID, 10_000_000) + _fund(fake_pb, UID2, 10_000_000) + wins = {UID: 0, UID2: 0} + for seed in range(200): + # reset tickets each round to the same split + fake_pb.record_for(UID).update(lottery_tickets=9, lottery_period=P) + fake_pb.record_for(UID2).update(lottery_tickets=1, lottery_period=P) + random.seed(seed) + res = run(economy.do_lottery_draw(P)) + wins[res["winner_id"]] += 1 + # UID holds 90% of tickets -> should win far more often. + assert wins[UID] > wins[UID2] * 3