Files
tipibot/economy.py
2026-03-20 17:35:35 +02:00

1289 lines
48 KiB
Python

"""TipiCOIN economy - data layer and business logic.
Storage: PocketBase (see pb_client.py). Collection: economy_users.
All public async functions are the single source of truth for mutations.
"""
from __future__ import annotations
import logging
import math
import random
from datetime import date, datetime, timedelta, timezone
from typing import TypedDict
import pb_client
import strings
_txn_log = logging.getLogger("tipiCOIN.txn")
def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items())
_txn_log.info("%-16s %s", event, body)
# ---------------------------------------------------------------------------
# Emoji config
# To use your custom Discord emoji replace COIN with the full tag, e.g.:
# COIN = "<:tipicoin:1234567890123456789>"
# ---------------------------------------------------------------------------
COIN = "<:TipiCOIN:1483000209188589628>"
# ---------------------------------------------------------------------------
# Shop catalogue
# ---------------------------------------------------------------------------
class ShopItem(TypedDict):
name: str
emoji: str
cost: int
description: str
SHOP: dict[str, ShopItem] = {
"gaming_hiir": {
"name": "Mängurihiir",
"emoji": "<:TipiHIIR:1483004306012504128>",
"cost": 500,
"description": strings.ITEM_DESCRIPTIONS["gaming_hiir"],
},
"hiirematt": {
"name": "Hiirematt",
"emoji": "<:TipiMATT:1483387697132208128>",
"cost": 600,
"description": strings.ITEM_DESCRIPTIONS["hiirematt"],
},
"korvaklapid": {
"name": "K\u00f5rvaklapid",
"emoji": "<:TipiKLAPID:1483387694083084349>",
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["korvaklapid"],
},
"lan_pass": {
"name": "LAN pilet",
"emoji": "<:TipiPILET:1483004308353060904>",
"cost": 1200,
"description": strings.ITEM_DESCRIPTIONS["lan_pass"],
},
"energiajook": {
"name": "Red Bull",
"emoji": "<:TipiBULL:1483004310924300409>",
"cost": 800,
"description": strings.ITEM_DESCRIPTIONS["energiajook"],
},
"gaming_laptop": {
"name": "Bot Farm",
"emoji": "<:TipiLAP:1483004307161874566>",
"cost": 1500,
"description": strings.ITEM_DESCRIPTIONS["gaming_laptop"],
},
"anticheat": {
"name": "Anticheat",
"emoji": "<:TipiVAC:1483004309510819860>",
"cost": 1000,
"description": strings.ITEM_DESCRIPTIONS["anticheat"],
},
# ----- Tier 2 -----
"reguleeritav_laud": {
"name": "Reguleeritav laud",
"emoji": "<:TipiLAUD:1483387695576125440>",
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"],
},
"jellyfin": {
"name": "Jellyfin server",
"emoji": "<:TipiSERVER:1483387701032910969>",
"cost": 4000,
"description": strings.ITEM_DESCRIPTIONS["jellyfin"],
},
"mikrofon": {
"name": "Eraldiseisev mikrofon",
"emoji": "<:TipiMIC:1483387698499551313>",
"cost": 2800,
"description": strings.ITEM_DESCRIPTIONS["mikrofon"],
},
"klaviatuur": {
"name": "Mehaaniline klaviatuur",
"emoji": "<:TipiKLAVA:1483014339228078140>",
"cost": 1800,
"description": strings.ITEM_DESCRIPTIONS["klaviatuur"],
},
"monitor": {
"name": "Ultralai monitor",
"emoji": "<:TipiMONITOR:1483014340327243908>",
"cost": 2500,
"description": strings.ITEM_DESCRIPTIONS["monitor"],
},
"cat6": {
"name": "Cat6 kaabel",
"emoji": "<:TipiCAT:1483014337663602718>",
"cost": 3500,
"description": strings.ITEM_DESCRIPTIONS["cat6"],
},
# ----- Tier 3 -----
"monitor_360": {
"name": "360Hz monitor",
"emoji": "<:TipiMONITOR2:1483387699514839162>",
"cost": 7500,
"description": strings.ITEM_DESCRIPTIONS["monitor_360"],
},
"karikas": {
"name": "TipiLAN karikas",
"emoji": "<:TipiKARIKAS:1483014841148112977>",
"cost": 6000,
"description": strings.ITEM_DESCRIPTIONS["karikas"],
},
"gaming_tool": {
"name": "Gaming tool",
"emoji": "<:TipiTOOL:1483014341648187613>",
"cost": 9000,
"description": strings.ITEM_DESCRIPTIONS["gaming_tool"],
},
}
# Tier grouping (used by /shop pagination)
SHOP_TIERS: dict[int, list[str]] = {
1: ["gaming_hiir", "hiirematt", "korvaklapid", "lan_pass", "energiajook", "anticheat", "gaming_laptop"],
2: ["reguleeritav_laud", "jellyfin", "mikrofon", "klaviatuur", "monitor", "cat6"],
3: ["monitor_360", "karikas", "gaming_tool"],
}
# Minimum level required to purchase Tier 2 / Tier 3 shop items
SHOP_LEVEL_REQ: dict[str, int] = {
"reguleeritav_laud": 10,
"jellyfin": 10,
"mikrofon": 10,
"klaviatuur": 10,
"monitor": 10,
"cat6": 10,
"monitor_360": 20,
"karikas": 20,
"gaming_tool": 20,
}
# ---------------------------------------------------------------------------
# EXP / Level system
# ---------------------------------------------------------------------------
# EXP awarded per successful action
EXP_REWARDS: dict[str, int] = {
"daily": 50,
"work": 25,
"beg": 5,
"crime_win": 15,
"rob_win": 15,
"gamble_win": 10,
"heist_win": 25,
}
def gamble_exp(bet: int) -> int:
"""Scale EXP for a gambling win by bet size.
Returns 0 for bets < 10 coins to close micro-bet EXP farming.
10-99 → 5, 100-999 → 10, 1 000-9 999 → 15, 10 000+ → 20, 100 000+ → 25 (cap).
"""
return min(25, max(0, int(math.log10(max(1, bet))) * 5))
ECONOMY_ROLE = "ECONOMY"
# Vanity role milestones: (min_level, role_name) - highest first
LEVEL_ROLES: list[tuple[int, str]] = [
(30, "TipiLEGEND"),
(20, "TipiCHAD"),
(10, "TipiHUSTLER"),
(5, "TipiGRINDER"),
(1, "TipiNOOB"),
]
def get_level(exp: int) -> int:
"""Level = max(1, floor(sqrt(exp/10))).
Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
return max(1, int(math.sqrt(max(0, exp) / 10)))
def exp_for_level(level: int) -> int:
"""Minimum cumulative EXP to reach this level."""
if level <= 1:
return 0
return level * level * 10
def level_role_name(level: int) -> str:
"""Return the vanity role name for a given level."""
for threshold, name in LEVEL_ROLES:
if level >= threshold:
return name
return LEVEL_ROLES[-1][1]
# ---------------------------------------------------------------------------
# Cooldowns
# ---------------------------------------------------------------------------
COOLDOWNS: dict[str, timedelta] = {
"daily": timedelta(hours=20),
"work": timedelta(hours=1),
"beg": timedelta(minutes=5),
"crime": timedelta(hours=2),
"rob": timedelta(hours=2),
}
JAIL_DURATION = timedelta(minutes=30)
HEIST_JAIL = timedelta(hours=1, minutes=30)
# ---------------------------------------------------------------------------
# User schema
# ---------------------------------------------------------------------------
class UserData(TypedDict, total=False):
balance: int
exp: int # lifetime EXP (resets each season)
last_daily: str | None
last_work: str | None
last_beg: str | None
last_crime: str | None
last_rob: str | None
last_heist: str | None
daily_streak: int
last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables
jailed_until: str | None # ISO datetime or None
jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for
eco_banned: bool # if True, user cannot use any economy commands
# Lifetime statistics
peak_balance: int
lifetime_earned: int
lifetime_lost: int
work_count: int
beg_count: int
total_wagered: int
biggest_win: int
biggest_loss: int
slots_jackpots: int
crimes_attempted: int
crimes_succeeded: int
times_jailed: int
total_bail_paid: int
heists_joined: int
heists_won: int
total_given: int
total_received: int
best_daily_streak: int
heist_global_cd_until: float
def _default_user() -> UserData:
return {
"balance": 0,
"exp": 0,
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"last_heist": None,
"daily_streak": 0,
"last_streak_date": None,
"items": [],
"item_uses": {},
"jailed_until": None,
"jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"],
"eco_banned": False,
# ── Lifetime stats ──────────────────────────────────────────────────
"peak_balance": 0,
"lifetime_earned": 0,
"lifetime_lost": 0,
"work_count": 0,
"beg_count": 0,
"total_wagered": 0,
"biggest_win": 0,
"biggest_loss": 0,
"slots_jackpots": 0,
"crimes_attempted": 0,
"crimes_succeeded": 0,
"times_jailed": 0,
"total_bail_paid": 0,
"heists_joined": 0,
"heists_won": 0,
"total_given": 0,
"total_received": 0,
"best_daily_streak": 0,
"heist_global_cd_until": 0.0,
}
# ---------------------------------------------------------------------------
# Persistence (PocketBase backend)
# ---------------------------------------------------------------------------
_log = logging.getLogger("tipiCOIN.economy")
# ---------------------------------------------------------------------------
# House account (bot user)
# ---------------------------------------------------------------------------
HOUSE_ID: int | None = None
def set_house(user_id: int) -> None:
"""Register the bot's Discord user ID as the house account."""
global HOUSE_ID
HOUSE_ID = user_id
async def _credit_house(amount: int) -> None:
"""Add `amount` coins to the house account. No-op if house not set."""
if HOUSE_ID is None or amount <= 0:
return
user = await get_user(HOUSE_ID)
user["balance"] = user.get("balance", 0) + amount
await _commit(HOUSE_ID, user)
async def get_heist_global_cd() -> float:
"""Return unix timestamp until which no new heist can start. Persisted on house record."""
if HOUSE_ID is None:
return 0.0
house = await get_user(HOUSE_ID)
return float(house.get("heist_global_cd_until") or 0)
async def set_heist_global_cd(until: float) -> None:
"""Persist heist global cooldown expiry to the house account in PocketBase."""
if HOUSE_ID is None:
return
house = await get_user(HOUSE_ID)
house["heist_global_cd_until"] = until
await _commit(HOUSE_ID, house)
async def do_spam_jail(user_id: int) -> None:
"""Jail a user for 30 minutes due to suspected automated command spam."""
user = await get_user(user_id)
user["jailed_until"] = (_now() + timedelta(minutes=30)).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
await _commit(user_id, user)
_txn("SPAM_JAIL", user=user_id, until=user["jailed_until"])
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
async def get_all_users_raw() -> dict[str, "UserData"]:
"""Return a snapshot of all user records."""
records = await pb_client.list_all_records()
result: dict[str, UserData] = {}
for record in records:
uid = record.get("user_id", "")
if not uid:
continue
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
result[uid] = user
return result
async def migrate_anticheat_uses() -> int:
"""One-time migration: users who own anticheat but have no item_uses entry get 2 uses."""
records = await pb_client.list_all_records()
changed = 0
for record in records:
items = record.get("items") or []
item_uses = record.get("item_uses") or {}
if "anticheat" in items and "anticheat" not in item_uses:
item_uses["anticheat"] = 2
await pb_client.update_record(record["id"], {"item_uses": item_uses})
changed += 1
return changed
async def migrate_reminders_default() -> int:
"""One-time migration: enable all reminders for users who have an empty list."""
_ALL_REMINDERS = ["daily", "work", "beg", "crime", "rob"]
records = await pb_client.list_all_records()
changed = 0
for record in records:
reminders = record.get("reminders")
if not reminders:
await pb_client.update_record(record["id"], {"reminders": _ALL_REMINDERS})
changed += 1
return changed
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _now() -> datetime:
return datetime.now(tz=timezone.utc)
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
dt = datetime.fromisoformat(s)
# Ensure timezone-aware
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _cooldown_remaining(
user: UserData, action: str, override_cd: timedelta | None = None
) -> timedelta | None:
"""Return remaining cooldown, or None if the action is ready."""
last = _parse_dt(user.get(f"last_{action}"))
if last is None:
return None
cd = override_cd if override_cd is not None else COOLDOWNS[action]
remaining = cd - (_now() - last)
return remaining if remaining.total_seconds() > 0 else None
def _is_jailed(user: UserData) -> timedelta | None:
"""Return remaining jail time, or None if free."""
until = _parse_dt(user.get("jailed_until"))
if until is None:
return None
remaining = until - _now()
return remaining if remaining.total_seconds() > 0 else None
def jailed_remaining(user: UserData) -> timedelta | None:
"""Public wrapper - return remaining jail time, or None if free."""
return _is_jailed(user)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def format_td(td: timedelta) -> str:
"""Human-readable timedelta: '1t 23m' / '45m 12s' / '8s'."""
total = int(td.total_seconds())
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}t {m}m"
if m:
return f"{m}m {s}s"
return f"{s}s"
async def get_user(user_id: int) -> UserData:
"""Fetch user data from PocketBase, creating a default record if first seen."""
uid = str(user_id)
record = await pb_client.get_record(uid)
if record is None:
default = _default_user()
default["user_id"] = uid # type: ignore[typeddict-unknown-key]
record = await pb_client.create_record(default)
user = _default_user()
for key in list(user.keys()):
if key in record:
user[key] = record[key] # type: ignore[literal-required]
user["_pb_id"] = record["id"] # type: ignore[typeddict-unknown-key]
return user
async def get_leaderboard(top_n: int | None = 10) -> list[tuple[str, int]]:
"""Return top_n (user_id_str, balance) pairs sorted descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("balance", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
return result if top_n is None else result[:top_n]
async def get_leaderboard_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]:
"""Return top_n (user_id_str, exp, level) sorted by EXP descending."""
records = await pb_client.list_all_records()
result = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)
entries = [(uid, exp, get_level(exp)) for uid, exp in result]
return entries if top_n is None else entries[:top_n]
async def award_exp(user_id: int, amount: int) -> dict:
"""Add EXP to a user. Returns old_level, new_level, total exp."""
user = await get_user(user_id)
old_exp = user.get("exp", 0)
new_exp = old_exp + amount
old_level = get_level(old_exp)
new_level = get_level(new_exp)
user["exp"] = new_exp
await _commit(user_id, user)
return {"old_level": old_level, "new_level": new_level, "exp": new_exp}
async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
"""Snapshot top_n by EXP, then full wipe: EXP, balance, items, item_uses.
Returns top list (uid, exp, level) captured before the reset."""
records = await pb_client.list_all_records()
top = sorted(
((r["user_id"], r.get("exp", 0)) for r in records if r.get("user_id")),
key=lambda x: x[1],
reverse=True,
)[:top_n]
reset_fields = {
"exp": 0,
"balance": 0,
"items": [],
"item_uses": {},
"last_daily": None,
"last_work": None,
"last_beg": None,
"last_crime": None,
"last_rob": None,
"daily_streak": 0,
"last_streak_date": None,
}
for record in records:
await pb_client.update_record(record["id"], reset_fields)
return [(uid, exp, get_level(exp)) for uid, exp in top]
# ---------------------------------------------------------------------------
# Internal write helper
# ---------------------------------------------------------------------------
async def _commit(user_id: int, user: UserData) -> None:
try:
record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id)
if record_id:
await pb_client.update_record(record_id, clean)
else:
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
created = await pb_client.create_record(clean)
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
except Exception as exc:
_log.error("_commit failed for user %s: %s", user_id, exc)
# ---------------------------------------------------------------------------
# /daily
# ---------------------------------------------------------------------------
async def do_daily(user_id: int) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
daily_cd = timedelta(hours=18) if "korvaklapid" in user["items"] else COOLDOWNS["daily"]
if cd := _cooldown_remaining(user, "daily", override_cd=daily_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
today = _now().date()
last_str = user.get("last_streak_date")
last_date = date.fromisoformat(last_str) if last_str else None
if last_date is None:
streak = 1
elif (today - last_date).days == 1:
streak = user["daily_streak"] + 1
elif "karikas" in user["items"]:
streak = user["daily_streak"] # karikas: streak survives missed days
else:
streak = 1 # streak broken
# Streak multiplier tiers
if streak >= 14:
streak_mult = 3.0
elif streak >= 7:
streak_mult = 2.0
elif streak >= 3:
streak_mult = 1.5
else:
streak_mult = 1.0
vip = "lan_pass" in user["items"]
vip_mult = 2.0 if vip else 1.0
base = 150
earned = int(base * streak_mult * vip_mult)
# Investor interest (capped at 500/day to prevent runaway wealth)
interest = 0
if "gaming_laptop" in user["items"] and user["balance"] > 0:
interest = min(int(user["balance"] * 0.05), 500)
earned += interest
user["balance"] += earned
user["last_daily"] = _now().isoformat()
user["daily_streak"] = streak
user["last_streak_date"] = today.isoformat()
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["best_daily_streak"] = max(user.get("best_daily_streak", 0), streak)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("DAILY", user=user_id, earned=f"+{earned}", streak=streak, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"interest": interest,
"streak": streak,
"streak_mult": streak_mult,
"vip": vip,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /work
# ---------------------------------------------------------------------------
_WORK_JOBS = strings.WORK_JOBS
async def do_work(user_id: int) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
work_cd = timedelta(minutes=40) if "monitor" in user["items"] else COOLDOWNS["work"]
if cd := _cooldown_remaining(user, "work", override_cd=work_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
job, job_mult = random.choice(_WORK_JOBS)
base = random.randint(15, 75)
worker_mult = 1.5 if "gaming_hiir" in user["items"] else 1.0
desk_mult = 1.25 if "reguleeritav_laud" in user["items"] else 1.0
lucky = False
if "energiajook" in user["items"] and random.random() < 0.30:
lucky = True
earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0))
user["balance"] += earned
user["last_work"] = _now().isoformat()
user["work_count"] = user.get("work_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("WORK", user=user_id, earned=f"+{earned}", lucky=lucky, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"job": job,
"lucky": lucky,
"hiir": worker_mult > 1.0,
"laud": desk_mult > 1.0,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /beg
# ---------------------------------------------------------------------------
_BEG_LINES = strings.BEG_LINES
_BEG_JAIL_LINES = strings.BEG_JAIL_LINES
async def do_beg(user_id: int) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
beg_cd = timedelta(minutes=3) if "hiirematt" in user["items"] else COOLDOWNS["beg"]
if cd := _cooldown_remaining(user, "beg", override_cd=beg_cd):
return {"ok": False, "reason": "cooldown", "remaining": cd}
jailed = bool(_is_jailed(user))
beg_mult = 2 if "klaviatuur" in user["items"] else 1
earned = random.randint(10, 40) * beg_mult
user["balance"] += earned
user["last_beg"] = _now().isoformat()
user["beg_count"] = user.get("beg_count", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("BEG", user=user_id, earned=f"+{earned}", jailed=jailed, bal=user["balance"])
return {
"ok": True,
"earned": earned,
"text": random.choice(_BEG_JAIL_LINES if jailed else _BEG_LINES),
"klaviatuur": beg_mult > 1,
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /crime
# ---------------------------------------------------------------------------
_CRIME_WIN = strings.CRIME_WIN
_CRIME_LOSE = strings.CRIME_LOSE
async def do_crime(user_id: int) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if cd := _cooldown_remaining(user, "crime"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
user["last_crime"] = _now().isoformat()
win_chance = 0.75 if "cat6" in user["items"] else 0.60
user["crimes_attempted"] = user.get("crimes_attempted", 0) + 1
if random.random() < win_chance:
earned = random.randint(200, 500)
if "mikrofon" in user["items"]:
earned = int(earned * 1.3)
user["balance"] += earned
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
await _commit(user_id, user)
_txn("CRIME_WIN", user=user_id, earned=f"+{earned}", bal=user["balance"])
return {
"ok": True, "success": True,
"earned": earned, "text": random.choice(_CRIME_WIN),
"mikrofon": "mikrofon" in user["items"],
"balance": user["balance"],
}
else:
fine = random.randint(50, 150)
user["balance"] = max(0, user["balance"] - fine)
jailed = "gaming_tool" not in user["items"]
if jailed:
user["jailed_until"] = (_now() + JAIL_DURATION).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
await _commit(user_id, user)
await _credit_house(fine)
_txn("CRIME_FAIL", user=user_id, fine=f"-{fine}", jailed=jailed, bal=user["balance"])
return {
"ok": True, "success": False,
"fine": fine, "text": random.choice(_CRIME_LOSE),
"jailed": jailed,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /jailbreak (Monopoly-style dice rolls)
# ---------------------------------------------------------------------------
async def set_jailbreak_used(user_id: int) -> None:
"""Mark that the user has consumed their dice attempt for this jail sentence."""
user = await get_user(user_id)
user["jailbreak_used"] = True
await _commit(user_id, user)
async def do_jail_free(user_id: int) -> dict:
"""Remove jail status after rolling doubles."""
user = await get_user(user_id)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(user_id, user)
_txn("JAIL_FREE", user=user_id, method="doubles")
return {"ok": True, "balance": user["balance"]}
MIN_BAIL = 350
async def do_bail(user_id: int) -> dict:
"""Charge bail fine after exhausting jailbreak rolls and free the user.
Fine = 20-30% of current balance, floored at 350. If balance < 350, stay jailed."""
user = await get_user(user_id)
if user["balance"] < MIN_BAIL:
return {"ok": False, "reason": "broke", "balance": user["balance"]}
pct = random.uniform(0.20, 0.30)
fine = max(MIN_BAIL, int(user["balance"] * pct))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = None
user["jailbreak_used"] = False
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
user["total_bail_paid"] = user.get("total_bail_paid", 0) + fine
await _commit(user_id, user)
_txn("BAIL_PAID", user=user_id, fine=f"-{fine}", pct=f"{pct:.0%}", bal=user["balance"])
return {"ok": True, "fine": fine, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /rob
# ---------------------------------------------------------------------------
async def do_rob(robber_id: int, target_id: int) -> dict:
robber = await get_user(robber_id)
if robber.get("eco_banned"):
return {"ok": False, "reason": "banned"}
target = await get_user(target_id)
if cd := _cooldown_remaining(robber, "rob"):
return {"ok": False, "reason": "cooldown", "remaining": cd}
target_jailed = bool(_is_jailed(target))
if jail := _is_jailed(robber):
if not target_jailed:
return {"ok": False, "reason": "jailed", "remaining": jail}
elif target_jailed:
return {"ok": False, "reason": "target_jailed"}
is_house = (HOUSE_ID is not None and target_id == HOUSE_ID)
if is_house and target["balance"] < 50:
return {"ok": False, "reason": "broke"}
if not is_house and target["balance"] < 100:
return {"ok": False, "reason": "broke"}
robber["last_rob"] = _now().isoformat()
if "anticheat" in target["items"] and not is_house:
fine = random.randint(100, 200)
robber["balance"] = max(0, robber["balance"] - fine)
# Decrement anticheat uses
uses = target.get("item_uses", {}).get("anticheat", 2) - 1
if "item_uses" not in target:
target["item_uses"] = {}
if uses <= 0:
target["items"] = [i for i in target["items"] if i != "anticheat"]
target["item_uses"].pop("anticheat", None)
else:
target["item_uses"]["anticheat"] = uses
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
await _commit(robber_id, robber)
await _commit(target_id, target)
await _credit_house(fine)
_txn("ROB_BLOCKED", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"], ac_uses_left=uses)
return {"ok": True, "success": False, "reason": "valvur", "fine": fine}
# Robbing the house has lower success (35%) but jackpot chance
success_chance = 0.35 if is_house else (0.60 if "jellyfin" in robber["items"] else 0.45)
if random.random() < success_chance:
jackpot = is_house and random.random() < 0.10
if jackpot:
pct = 0.40
elif is_house:
pct = random.uniform(0.05, 0.15)
else:
pct = random.uniform(0.10, 0.25)
stolen = max(10, min(int(target["balance"] * pct), target["balance"]))
target["balance"] -= stolen
robber["balance"] += stolen
robber["lifetime_earned"] = robber.get("lifetime_earned", 0) + stolen
robber["biggest_win"] = max(robber.get("biggest_win", 0), stolen)
robber["peak_balance"] = max(robber.get("peak_balance", 0), robber["balance"])
await _commit(robber_id, robber)
await _commit(target_id, target)
_txn("ROB_WIN", robber=robber_id, victim=target_id, stolen=f"+{stolen}", jackpot=jackpot, robber_bal=robber["balance"], victim_bal=target["balance"])
return {"ok": True, "success": True, "stolen": stolen, "balance": robber["balance"], "jackpot": jackpot}
else:
fine = random.randint(100, 250)
robber["balance"] = max(0, robber["balance"] - fine)
robber["lifetime_lost"] = robber.get("lifetime_lost", 0) + fine
robber["biggest_loss"] = max(robber.get("biggest_loss", 0), fine)
await _commit(robber_id, robber)
await _credit_house(fine)
_txn("ROB_FAIL", robber=robber_id, victim=target_id, fine=f"-{fine}", robber_bal=robber["balance"])
return {"ok": True, "success": False, "reason": "caught", "fine": fine, "balance": robber["balance"]}
# ---------------------------------------------------------------------------
# /roulette
# ---------------------------------------------------------------------------
async def do_roulette(user_id: int, bet: int, colour: str) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
# Wheel: 18 red, 18 black, 1 green (37 slots - real roulette proportions)
result = random.choices(["punane", "must", "roheline"], weights=[18, 18, 1])[0]
won = result == colour
mult = 14 if colour == "roheline" else 1
change = bet * mult if won else -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if won:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + abs(change)
user["biggest_win"] = max(user.get("biggest_win", 0), abs(change))
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if not won:
await _credit_house(bet)
_txn("ROULETTE_" + ("WIN" if won else "LOSE"), user=user_id, bet=bet, colour=colour, result=result, mult=mult, bal=user["balance"])
return {
"ok": True, "won": won,
"result": result, "change": abs(change), "mult": mult,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /rps (bet resolution)
# ---------------------------------------------------------------------------
async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict:
"""Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'."""
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
user["total_wagered"] = user.get("total_wagered", 0) + bet
if outcome == "win":
user["balance"] += bet
user["lifetime_earned"] = user.get("lifetime_earned", 0) + bet
user["biggest_win"] = max(user.get("biggest_win", 0), bet)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif outcome == "lose":
user["balance"] = max(0, user["balance"] - bet)
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
# tie: no change
await _commit(user_id, user)
if outcome == "lose" and bet > 0:
await _credit_house(bet)
_txn("RPS_" + outcome.upper(), user=user_id, bet=bet, bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /slots
# ---------------------------------------------------------------------------
_SLOTS_SYMBOLS: list[tuple[str, int]] = [
("<:TipiHEART:1483431377561976853>", 27),
("<:TipiFIRE:1483431381668335687>", 22),
("<:TipiTROLL:1483431380166774895>", 18),
("<:TipICRY:1483431288852709387>", 15),
("<:TipiSKULL:1483431378929451028>", 10),
("<:TipiKARIKAS:1483014841148112977>", 8),
]
_SLOTS_JACKPOT = "<:TipiKARIKAS:1483014841148112977>"
_SLOTS_TRIPLE_MULT: dict[str, int] = {
"<:TipiHEART:1483431377561976853>": 4,
"<:TipiFIRE:1483431381668335687>": 5,
"<:TipiTROLL:1483431380166774895>": 7,
"<:TipICRY:1483431288852709387>": 10,
"<:TipiSKULL:1483431378929451028>": 15,
"<:TipiKARIKAS:1483014841148112977>": 25, # jackpot
}
def _spin() -> str:
symbols, weights = zip(*_SLOTS_SYMBOLS)
return random.choices(list(symbols), weights=list(weights), k=1)[0]
async def do_slots(user_id: int, bet: int) -> dict:
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient"}
reels = [_spin(), _spin(), _spin()]
a, b, c = reels
has_360 = "monitor_360" in user["items"]
if a == b == c:
tier = "jackpot" if a == _SLOTS_JACKPOT else "triple"
base_mult = _SLOTS_TRIPLE_MULT.get(a, 4)
mult = int(base_mult * 1.5) if has_360 else base_mult
change = bet * (mult - 1)
elif a == b or b == c or a == c:
tier = "pair"
change = bet // 2
else:
tier = "miss"
change = -bet
user["balance"] = max(0, user["balance"] + change)
user["total_wagered"] = user.get("total_wagered", 0) + bet
if tier in ("jackpot", "triple", "pair"):
user["lifetime_earned"] = user.get("lifetime_earned", 0) + change
user["biggest_win"] = max(user.get("biggest_win", 0), change)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
if tier == "jackpot":
user["slots_jackpots"] = user.get("slots_jackpots", 0) + 1
else:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + bet
user["biggest_loss"] = max(user.get("biggest_loss", 0), bet)
await _commit(user_id, user)
if tier == "miss":
await _credit_house(bet)
_txn("SLOTS_" + tier.upper(), user=user_id, bet=bet, change=change, bal=user["balance"])
return {
"ok": True,
"reels": reels,
"tier": tier,
"change": change,
"balance": user["balance"],
}
# ---------------------------------------------------------------------------
# /give
# ---------------------------------------------------------------------------
async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict:
giver = await get_user(giver_id)
if giver.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if rem := _is_jailed(giver):
return {"ok": False, "reason": "jailed", "remaining": rem}
if giver["balance"] < amount:
return {"ok": False, "reason": "insufficient"}
receiver = await get_user(receiver_id)
giver["balance"] -= amount
receiver["balance"] += amount
giver["total_given"] = giver.get("total_given", 0) + amount
receiver["total_received"] = receiver.get("total_received", 0) + amount
await _commit(giver_id, giver)
await _commit(receiver_id, receiver)
_txn("GIVE", from_=giver_id, to=receiver_id, amount=amount, from_bal=giver["balance"], to_bal=receiver["balance"])
return {
"ok": True,
"giver_balance": giver["balance"],
"receiver_balance": receiver["balance"],
}
# ---------------------------------------------------------------------------
# /buy
# ---------------------------------------------------------------------------
async def do_buy(user_id: int, item_id: str) -> dict:
if item_id not in SHOP:
return {"ok": False, "reason": "not_found"}
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
item = SHOP[item_id]
if item_id in user["items"]:
# Allow repurchase of anticheat if uses are depleted
if item_id == "anticheat" and user.get("item_uses", {}).get("anticheat", 2) <= 0:
user["items"] = [i for i in user["items"] if i != "anticheat"]
user.get("item_uses", {}).pop("anticheat", None)
else:
return {"ok": False, "reason": "owned"}
min_level = SHOP_LEVEL_REQ.get(item_id, 0)
if min_level > 0:
user_level = get_level(user.get("exp", 0))
if user_level < min_level:
return {"ok": False, "reason": "level_required", "min_level": min_level, "user_level": user_level}
if user["balance"] < item["cost"]:
return {"ok": False, "reason": "insufficient", "need": item["cost"] - user["balance"]}
user["balance"] -= item["cost"]
user["items"].append(item_id)
if item_id == "anticheat":
if "item_uses" not in user:
user["item_uses"] = {}
user["item_uses"]["anticheat"] = 2
await _commit(user_id, user)
_txn("BUY", user=user_id, item=item_id, cost=f"-{item['cost']}", bal=user["balance"])
return {"ok": True, "item": item, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# Admin actions
# ---------------------------------------------------------------------------
async def do_admin_coins(target_id: int, amount: int, admin_id: int, reason: str) -> dict:
"""Give (positive) or take (negative) coins from a user. Balance is floored at 0."""
user = await get_user(target_id)
user["balance"] = max(0, user["balance"] + amount)
await _commit(target_id, user)
verb = f"+{amount}" if amount >= 0 else str(amount)
_txn("ADMIN_COINS", admin=admin_id, target=target_id, amount=verb, reason=reason, bal=user["balance"])
return {"ok": True, "balance": user["balance"], "change": amount}
async def do_admin_jail(target_id: int, minutes: int, admin_id: int, reason: str) -> dict:
"""Manually jail a user for `minutes` minutes."""
user = await get_user(target_id)
user["jailed_until"] = (_now() + timedelta(minutes=minutes)).isoformat()
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_JAIL", admin=admin_id, target=target_id, minutes=minutes, reason=reason)
return {"ok": True, "jailed_until": user["jailed_until"]}
async def do_admin_unjail(target_id: int, admin_id: int) -> dict:
"""Remove jail from a user."""
user = await get_user(target_id)
user["jailed_until"] = None
user["jailbreak_used"] = False
await _commit(target_id, user)
_txn("ADMIN_UNJAIL", admin=admin_id, target=target_id)
return {"ok": True}
async def do_admin_ban(target_id: int, admin_id: int, reason: str) -> dict:
"""Ban a user from all economy commands."""
user = await get_user(target_id)
user["eco_banned"] = True
await _commit(target_id, user)
_txn("ADMIN_BAN", admin=admin_id, target=target_id, reason=reason)
return {"ok": True}
async def do_admin_unban(target_id: int, admin_id: int) -> dict:
"""Lift an economy ban."""
user = await get_user(target_id)
user["eco_banned"] = False
await _commit(target_id, user)
_txn("ADMIN_UNBAN", admin=admin_id, target=target_id)
return {"ok": True}
async def do_admin_reset(target_id: int, admin_id: int) -> dict:
"""Wipe a user's economy data back to defaults."""
user = await get_user(target_id)
fresh = _default_user()
fresh["_pb_id"] = user.get("_pb_id") # type: ignore[typeddict-unknown-key]
await _commit(target_id, fresh)
_txn("ADMIN_RESET", admin=admin_id, target=target_id)
return {"ok": True}
async def do_admin_inspect(target_id: int) -> dict:
"""Return the user's full raw economy data."""
user = await get_user(target_id)
return {"ok": True, "data": dict(user)}
# ---------------------------------------------------------------------------
# /reminders
# ---------------------------------------------------------------------------
async def do_set_reminders(user_id: int, commands: list[str]) -> None:
"""Overwrite the user's reminder list with the given command names."""
user = await get_user(user_id)
user["reminders"] = list(commands)
await _commit(user_id, user)
# ---------------------------------------------------------------------------
# /blackjack
# ---------------------------------------------------------------------------
async def do_blackjack_bet(user_id: int, bet: int) -> dict:
"""Deduct the initial blackjack bet. Returns ok/fail."""
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
if user["balance"] < bet:
return {"ok": False, "reason": "insufficient", "balance": user["balance"]}
user["balance"] -= bet
await _commit(user_id, user)
return {"ok": True, "balance": user["balance"]}
async def do_blackjack_payout(user_id: int, payout: int, total_invested: int = 0) -> dict:
"""Credit the net payout. House receives the difference when payout < total_invested."""
user = await get_user(user_id)
user["balance"] += payout
user["balance"] = max(0, user["balance"])
user["total_wagered"] = user.get("total_wagered", 0) + total_invested
net = payout - total_invested
if net > 0:
user["lifetime_earned"] = user.get("lifetime_earned", 0) + net
user["biggest_win"] = max(user.get("biggest_win", 0), net)
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
elif net < 0:
user["lifetime_lost"] = user.get("lifetime_lost", 0) + abs(net)
user["biggest_loss"] = max(user.get("biggest_loss", 0), abs(net))
await _commit(user_id, user)
house_gain = total_invested - payout
if house_gain > 0:
await _credit_house(house_gain)
_txn("BLACKJACK", user=user_id, payout=f"{payout:+}", net=f"{net:+}", bal=user["balance"])
return {"ok": True, "balance": user["balance"]}
# ---------------------------------------------------------------------------
# /jailed
# ---------------------------------------------------------------------------
async def do_get_jailed() -> list[tuple[int, timedelta]]:
"""Return [(user_id, remaining)] for every user currently in jail."""
all_users = await get_all_users_raw()
result: list[tuple[int, timedelta]] = []
for uid_str, user in all_users.items():
if rem := _is_jailed(user):
result.append((int(uid_str), rem))
result.sort(key=lambda x: x[1])
return result
# ---------------------------------------------------------------------------
# /heist
# ---------------------------------------------------------------------------
async def do_heist_check(user_id: int) -> dict:
"""Check whether a user is eligible to join a heist."""
user = await get_user(user_id)
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
if jail := _is_jailed(user):
return {"ok": False, "reason": "jailed", "remaining": jail}
return {"ok": True}
async def do_heist_resolve(user_ids: list[int], success: bool) -> dict:
"""Apply heist outcome to all participants. On win, steals from house."""
now = _now()
payout_each = 0
if success and HOUSE_ID is not None:
house = await get_user(HOUSE_ID)
pct = random.uniform(0.20, 0.55)
total = max(300, int(house["balance"] * pct))
payout_each = total // len(user_ids)
house["balance"] = max(0, house["balance"] - total)
await _commit(HOUSE_ID, house)
_txn("HEIST_HOUSE", change=f"-{total}", house_bal=house["balance"])
for uid in user_ids:
user = await get_user(uid)
user["last_heist"] = now.isoformat()
user["heists_joined"] = user.get("heists_joined", 0) + 1
if success:
user["balance"] += payout_each
user["heists_won"] = user.get("heists_won", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + payout_each
user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"])
_txn("HEIST_WIN", user=uid, change=f"+{payout_each}", bal=user["balance"])
else:
fine = max(150, min(1000, int(user["balance"] * 0.15)))
user["balance"] = max(0, user["balance"] - fine)
user["jailed_until"] = (now + HEIST_JAIL).isoformat()
user["jailbreak_used"] = False
user["times_jailed"] = user.get("times_jailed", 0) + 1
user["lifetime_lost"] = user.get("lifetime_lost", 0) + fine
_txn("HEIST_FAIL", user=uid, fine=f"-{fine}", jailed_until=user["jailed_until"], bal=user["balance"])
if fine > 0:
await _credit_house(fine)
await _commit(uid, user)
return {"ok": True, "payout_each": payout_each, "success": success}