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:
@@ -31,6 +31,12 @@ BIRTHDAY_CHANNEL_ID=
|
|||||||
# How many days before a birthday the on-join check counts as "coming up"
|
# How many days before a birthday the on-join check counts as "coming up"
|
||||||
BIRTHDAY_WINDOW_DAYS=7
|
BIRTHDAY_WINDOW_DAYS=7
|
||||||
|
|
||||||
|
# Channel ID where the daily lottery draw result is announced (optional - the
|
||||||
|
# draw still runs and pays the winner if unset; per-profile like BIRTHDAY_CHANNEL)
|
||||||
|
LOTTERY_CHANNEL_ID_DEV=
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY=
|
||||||
|
LOTTERY_CHANNEL_ID=
|
||||||
|
|
||||||
# PocketBase backend (https://pocketbase.io)
|
# PocketBase backend (https://pocketbase.io)
|
||||||
PB_URL=http://127.0.0.1:8090
|
PB_URL=http://127.0.0.1:8090
|
||||||
PB_ADMIN_EMAIL=admin@example.com
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ The **ECONOMY** role is granted on your first EXP award (i.e. first successful e
|
|||||||
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
| `/reminders` | Toggle per-command DM notifications. Bot DMs you the moment each cooldown expires. |
|
||||||
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
| `/quests` | Personal daily (3) and weekly (2) quests with progress bars and a claim button. |
|
||||||
| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. |
|
| `/achievements` | Milestone badges over your lifetime stats (work/wealth/gambling/crime/fishing/streaks/prestige). Each unlocks once and pays a one-time coin reward; opening the command claims any newly earned. |
|
||||||
|
| `/lottery [amount]` | View the pot or buy tickets (200 ⬡ each, max 100/draw). One winner is drawn daily at 21:00 Tallinn time and takes the whole pot — more tickets = higher weighted chance. Coins are conserved (the pot equals total ticket spend). Announced in `LOTTERY_CHANNEL_ID` if set. |
|
||||||
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
| `/fish` | Interactive fishing minigame. Cast, wait for bite, pull, then keep or sell. 2min cooldown (90s with Ussipurk). |
|
||||||
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
| `/fishbook` | View your fish collection - all caught species, rarity, count, and current inventory amounts. |
|
||||||
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
| `/fishsell` | Sell all fish currently in your inventory at once. |
|
||||||
|
|||||||
71
bot.py
71
bot.py
@@ -336,6 +336,72 @@ async def before_birthday_daily():
|
|||||||
await bot.wait_until_ready()
|
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
|
# Rotating rich presence
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -433,6 +499,11 @@ async def on_ready():
|
|||||||
birthday_daily.start()
|
birthday_daily.start()
|
||||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
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
|
# Start rotating rich presence
|
||||||
if not _rotate_presence.is_running():
|
if not _rotate_presence.is_running():
|
||||||
_rotate_presence.start()
|
_rotate_presence.start()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import datetime
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable, MutableSet
|
from collections.abc import Awaitable, Callable, MutableSet
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
@@ -15,6 +16,8 @@ import strings as S
|
|||||||
|
|
||||||
from ._replies import reply_db_error
|
from ._replies import reply_db_error
|
||||||
|
|
||||||
|
_TALLINN = ZoneInfo("Europe/Tallinn")
|
||||||
|
|
||||||
|
|
||||||
def register_economy_extra_commands(
|
def register_economy_extra_commands(
|
||||||
tree: app_commands.CommandTree,
|
tree: app_commands.CommandTree,
|
||||||
@@ -554,6 +557,64 @@ def register_economy_extra_commands(
|
|||||||
)
|
)
|
||||||
await interaction.response.send_message(embed=embed)
|
await interaction.response.send_message(embed=embed)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# /lottery - daily draw, one weighted winner takes the pot
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
@tree.command(name="lottery", description=S.CMD["lottery"])
|
||||||
|
@app_commands.describe(kogus=S.OPT["lottery_kogus"])
|
||||||
|
async def cmd_lottery(interaction: discord.Interaction, kogus: int | None = None):
|
||||||
|
period = economy.period_for(datetime.datetime.now(_TALLINN))
|
||||||
|
|
||||||
|
if kogus is None:
|
||||||
|
state = await economy.get_lottery_state(period, interaction.user.id)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=S.LOTTERY_UI["title"],
|
||||||
|
description=S.LOTTERY_UI["desc"].format(
|
||||||
|
draw_time=f"{economy.DRAW_HOUR}:00", cost=coin(state["ticket_cost"])
|
||||||
|
),
|
||||||
|
color=0xF4C430,
|
||||||
|
)
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_pot"], value=coin(state["pot"]), inline=True)
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_players"], value=str(state["participants"]), inline=True)
|
||||||
|
if state["your_tickets"]:
|
||||||
|
chance = round(state["your_tickets"] / state["total_tickets"] * 100, 1)
|
||||||
|
embed.add_field(
|
||||||
|
name=S.LOTTERY_UI["f_your"],
|
||||||
|
value=S.LOTTERY_UI["your_val"].format(tickets=state["your_tickets"], chance=chance),
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed.add_field(name=S.LOTTERY_UI["f_your"], value=S.LOTTERY_UI["your_none"], inline=False)
|
||||||
|
await interaction.response.send_message(embed=embed)
|
||||||
|
return
|
||||||
|
|
||||||
|
if kogus <= 0:
|
||||||
|
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
res = await economy.do_buy_ticket(interaction.user.id, kogus, period)
|
||||||
|
if not res["ok"]:
|
||||||
|
if res["reason"] == "db_error":
|
||||||
|
await reply_db_error(interaction)
|
||||||
|
elif res["reason"] == "banned":
|
||||||
|
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
|
||||||
|
elif res["reason"] == "max_tickets":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.LOTTERY_UI["max_tickets"].format(cap=res["cap"], held=res["held"]), ephemeral=True
|
||||||
|
)
|
||||||
|
elif res["reason"] == "insufficient":
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.send_message(
|
||||||
|
S.LOTTERY_UI["bought"].format(
|
||||||
|
count=res["bought"], cost=coin(res["cost"]),
|
||||||
|
tickets=res["tickets"], balance=coin(res["balance"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
class LeaderboardView(discord.ui.View):
|
class LeaderboardView(discord.ui.View):
|
||||||
PER_PAGE = 10
|
PER_PAGE = 10
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ BIRTHDAY_CHANNEL_ID = (
|
|||||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
||||||
|
|
||||||
|
# Channel where the daily lottery draw result is announced. Optional - if unset,
|
||||||
|
# the draw still runs and pays the winner, it just isn't announced.
|
||||||
|
_LEGACY_LOTTERY_CHANNEL_ID = _env_int("LOTTERY_CHANNEL_ID", 0)
|
||||||
|
LOTTERY_CHANNEL_ID_DEV = _env_int("LOTTERY_CHANNEL_ID_DEV", _LEGACY_LOTTERY_CHANNEL_ID)
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY = _env_int("LOTTERY_CHANNEL_ID_ECONOMY", 0)
|
||||||
|
LOTTERY_CHANNEL_ID = (
|
||||||
|
LOTTERY_CHANNEL_ID_ECONOMY if BOT_PROFILE == "economy" else LOTTERY_CHANNEL_ID_DEV
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
||||||
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ from .prestige import *
|
|||||||
from .leaderboards import *
|
from .leaderboards import *
|
||||||
from .heist import *
|
from .heist import *
|
||||||
from .achievements import *
|
from .achievements import *
|
||||||
|
from .lottery import *
|
||||||
from .admin import *
|
from .admin import *
|
||||||
|
|
||||||
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
|
||||||
achievements, admin, bank, consumables, fishing, gambling, heist, house,
|
achievements, admin, bank, consumables, fishing, gambling, heist, house,
|
||||||
income, jail, leaderboards, levels, lootbox, prestige, quests, shop, store,
|
income, jail, leaderboards, levels, lootbox, lottery, prestige, quests,
|
||||||
vanity,
|
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
|
best_daily_streak: int
|
||||||
lootboxes_opened: int
|
lootboxes_opened: int
|
||||||
achievements_earned: list # ids of achievements already claimed
|
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
|
heist_global_cd_until: float
|
||||||
# Prestige system
|
# Prestige system
|
||||||
prestige_level: int
|
prestige_level: int
|
||||||
@@ -219,6 +221,8 @@ def _default_user() -> UserData:
|
|||||||
"best_daily_streak": 0,
|
"best_daily_streak": 0,
|
||||||
"lootboxes_opened": 0,
|
"lootboxes_opened": 0,
|
||||||
"achievements_earned": [],
|
"achievements_earned": [],
|
||||||
|
"lottery_tickets": 0,
|
||||||
|
"lottery_period": None,
|
||||||
"heist_global_cd_until": 0.0,
|
"heist_global_cd_until": 0.0,
|
||||||
# ── Prestige ─────────────────────────────────────────────────────────
|
# ── Prestige ─────────────────────────────────────────────────────────
|
||||||
"prestige_level": 0,
|
"prestige_level": 0,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ COLLECTIONS = sorted({config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLEC
|
|||||||
_TEXT_FIELDS = {
|
_TEXT_FIELDS = {
|
||||||
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
"last_daily", "last_work", "last_beg", "last_crime", "last_rob",
|
||||||
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
"last_heist", "last_fish", "last_streak_date", "jailed_until",
|
||||||
"vanity_active",
|
"vanity_active", "lottery_period",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from .economy import (
|
|||||||
LOOTBOX_UI,
|
LOOTBOX_UI,
|
||||||
BANK_UI,
|
BANK_UI,
|
||||||
ACHIEVEMENTS_UI,
|
ACHIEVEMENTS_UI,
|
||||||
|
LOTTERY_UI,
|
||||||
JAILED_UI,
|
JAILED_UI,
|
||||||
SHOP_BTN,
|
SHOP_BTN,
|
||||||
DAILY_UI,
|
DAILY_UI,
|
||||||
@@ -158,6 +159,7 @@ __all__ = [
|
|||||||
'LOOTBOX_UI',
|
'LOOTBOX_UI',
|
||||||
'BANK_UI',
|
'BANK_UI',
|
||||||
'ACHIEVEMENTS_UI',
|
'ACHIEVEMENTS_UI',
|
||||||
|
'LOTTERY_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ CMD: dict[str, str] = {
|
|||||||
"bank": "Vaata oma panka - röövikindel hoius",
|
"bank": "Vaata oma panka - röövikindel hoius",
|
||||||
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
|
"deposit": "Pane münte panka (röövikindel, aga ei teeni intressi)",
|
||||||
"withdraw": "Võta münte pangast rahakotti",
|
"withdraw": "Võta münte pangast rahakotti",
|
||||||
|
"lottery": "Vaata TipiLOTO potti või osta pileteid (tühjaks jättes näeb infot)",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -99,6 +100,7 @@ OPT: dict[str, str] = {
|
|||||||
"buy_ese": "Eseme nimi (vaata /shop)",
|
"buy_ese": "Eseme nimi (vaata /shop)",
|
||||||
"deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)",
|
"deposit_summa": "Kui palju panna panka? ('all' = kogu vaba raha)",
|
||||||
"withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)",
|
"withdraw_summa": "Kui palju pangast välja võtta? ('all' = kogu pangas)",
|
||||||
|
"lottery_kogus": "Mitu piletit osta (tühjaks jättes näeb potti)",
|
||||||
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
"consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)",
|
||||||
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
"vanity_ese": "Tiitel, mida osta või kanda (tühjaks jättes näeb poodi)",
|
||||||
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
"rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
|
||||||
@@ -161,6 +163,7 @@ HELP_CATEGORIES: dict[str, dict] = {
|
|||||||
("/withdraw <amount>", "Võta münte pangast rahakotti."),
|
("/withdraw <amount>", "Võta münte pangast rahakotti."),
|
||||||
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
||||||
("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."),
|
("/achievements", "Vaata oma saavutusi. Iga lukust lahti saanud märk annab ühekordse müntipreemia."),
|
||||||
|
("/lottery [kogus]", "Vaata TipiLOTO potti või osta pileteid. Loosimine iga päev - üks võitja saab kogu poti (rohkem pileteid = suurem võiduvõimalus)."),
|
||||||
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||||
("/shop", "Sirvi TipiBOTi poodi"),
|
("/shop", "Sirvi TipiBOTi poodi"),
|
||||||
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ __all__ = [
|
|||||||
'LOOTBOX_UI',
|
'LOOTBOX_UI',
|
||||||
'BANK_UI',
|
'BANK_UI',
|
||||||
'ACHIEVEMENTS_UI',
|
'ACHIEVEMENTS_UI',
|
||||||
|
'LOTTERY_UI',
|
||||||
'JAILED_UI',
|
'JAILED_UI',
|
||||||
'SHOP_BTN',
|
'SHOP_BTN',
|
||||||
'DAILY_UI',
|
'DAILY_UI',
|
||||||
@@ -302,6 +303,26 @@ ACHIEVEMENTS_UI: dict[str, str] = {
|
|||||||
"unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}",
|
"unlocked_note": "🎉 **Uued saavutused avatud:** {names}\n💰 Preemia: +{reward}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lottery (daily draw; one weighted winner takes the pot)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOTTERY_UI: dict[str, str] = {
|
||||||
|
"title": "🎟️ TipiLOTO",
|
||||||
|
"desc": "Osta pileteid ja võida kogu pott! Loosimine iga päev **{draw_time}**. Iga pilet = **{cost}**, rohkem pileteid = suurem võiduvõimalus.",
|
||||||
|
"f_pot": "💰 Praegune pott",
|
||||||
|
"f_players": "👥 Osalejaid",
|
||||||
|
"f_your": "🎟️ Sinu piletid",
|
||||||
|
"your_val": "{tickets} tk · võiduvõimalus **{chance}%**",
|
||||||
|
"your_none": "Sul pole veel pileteid. Osta käsuga `/lottery <kogus>`.",
|
||||||
|
"bought": "🎟️ Ostsid **{count}** piletit ({cost}).\nSul on nüüd **{tickets}** piletit selle päeva loosimises.\nSaldo: {balance}",
|
||||||
|
"max_tickets": "❌ Maksimaalne piletite arv ühes loosimises on {cap} (sul on {held}).",
|
||||||
|
"empty_pot": "🎟️ Pott on tühi - ole esimene, kes piletit ostab!",
|
||||||
|
# Draw announcement
|
||||||
|
"draw_title": "🎟️ TipiLOTO loosimine!",
|
||||||
|
"draw_win": "🎉 Võitja: {winner}\n💰 Võit: **{pot}**\n🎟️ {tickets}/{total} piletit ({chance}%)\n👥 {players} osalejat",
|
||||||
|
"draw_none": "🎟️ Täna keegi pileteid ei ostnud - loosimist ei toimunud.",
|
||||||
|
}
|
||||||
|
|
||||||
JAILED_UI: dict[str, str] = {
|
JAILED_UI: dict[str, str] = {
|
||||||
"title": "🔒 Praegu vanglas",
|
"title": "🔒 Praegu vanglas",
|
||||||
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
"empty": "Kõik on vabad! Vanglas pole kedagi.",
|
||||||
|
|||||||
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