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:
71
bot.py
71
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()
|
||||
|
||||
Reference in New Issue
Block a user