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

View File

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

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

View File

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