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:
131
tests/test_lottery.py
Normal file
131
tests/test_lottery.py
Normal 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
|
||||
Reference in New Issue
Block a user