1289 lines
48 KiB
Python
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}
|