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

131
tests/test_lottery.py Normal file
View File

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