forked from sass/tipibot
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:
@@ -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
149
core/economy/lottery.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user