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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
This commit is contained in:
Rene Arumetsa
2026-09-04 03:10:42 +03:00
parent ba044bf16f
commit d1cf6cdbd1
13 changed files with 462 additions and 3 deletions

149
core/economy/lottery.py Normal file
View File

@@ -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,
}