diff --git a/README.md b/README.md index 5b882fe..23976c2 100644 --- a/README.md +++ b/README.md @@ -486,7 +486,20 @@ Every slash command invocation is logged with the user ID, display name, and all ├── strings.py # All user-facing strings (command descriptions, help text, errors) ├── config.py # Environment variable loader ├── core/ -│ ├── economy.py # TipiCOIN business logic, constants (SHOP, COOLDOWNS, EXP_REWARDS, ...) +│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py) +│ │ ├── store.py # User records, per-user locks, time/cooldown helpers, txn log +│ │ ├── house.py # House account (fines in, heists out) +│ │ ├── levels.py # EXP, levels, vanity roles +│ │ ├── shop.py # Item catalogue + /buy +│ │ ├── income.py # /daily, /work, /beg, /crime, /rob, /give +│ │ ├── gambling.py # /roulette, /slots, /rps, /blackjack +│ │ ├── fishing.py # Fish catalogue + minigame flows +│ │ ├── quests.py # Daily/weekly quest system +│ │ ├── prestige.py # Prestige resets + upgrade shop +│ │ ├── jail.py # Jail, jailbreak, bail +│ │ ├── heist.py # Group heists against the house +│ │ ├── leaderboards.py # All leaderboard queries +│ │ └── admin.py # Admin mutations + season reset │ ├── pb_client.py # Async PocketBase REST client (auth + CRUD for economy_users) │ ├── sheets.py # Google Sheets read/write + in-memory cache │ └── member_sync.py # Role/nickname/birthday sync logic diff --git a/bot.py b/bot.py index 2fd659d..04a5682 100644 --- a/bot.py +++ b/bot.py @@ -244,7 +244,6 @@ async def _award_exp(interaction: discord.Interaction, amount: int) -> None: pass -@tree.interaction_check async def _log_command(interaction: discord.Interaction) -> bool: """Log every slash command invocation and enforce allowed-channel restriction.""" if interaction.command: @@ -287,6 +286,11 @@ async def _log_command(interaction: discord.Interaction) -> bool: return False +# CommandTree.interaction_check is a method meant to be overridden, not a +# decorator - `@tree.interaction_check` silently registers nothing. +tree.interaction_check = _log_command + + def _load_bday_log() -> dict: try: return json.loads(_BDAY_LOG.read_text(encoding="utf-8")) @@ -337,6 +341,8 @@ async def before_birthday_daily(): # --------------------------------------------------------------------------- _presence_index = 0 _economy_count: int = 0 +_economy_count_fetched: float = 0.0 +_ECONOMY_COUNT_TTL = 300 # seconds; the player count changes rarely _PRESENCES: list = [ lambda g: discord.Activity( type=discord.ActivityType.watching, @@ -358,12 +364,14 @@ _PRESENCES: list = [ @tasks.loop(seconds=20) async def _rotate_presence() -> None: - global _presence_index, _economy_count + global _presence_index, _economy_count, _economy_count_fetched guild = bot.get_guild(config.GUILD_ID) - try: - _economy_count = await pb_client.count_records() - except Exception as e: - log.warning("Presence: failed to fetch economy count: %s", e) + if time.monotonic() - _economy_count_fetched > _ECONOMY_COUNT_TTL: + try: + _economy_count = await pb_client.count_records() + _economy_count_fetched = time.monotonic() + except Exception as e: + log.warning("Presence: failed to fetch economy count: %s", e) activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild) await bot.change_presence(status=discord.Status.online, activity=activity) _presence_index += 1 @@ -382,6 +390,7 @@ async def on_ready(): """Load sheet data and sync slash commands on startup.""" log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) economy.set_house(bot.user.id) + _log_config_gaps() # PocketBase silently drops writes to fields missing from the collection # schema, so surface any drift loudly instead of letting features no-op. @@ -882,6 +891,25 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- +def _log_config_gaps() -> None: + """One startup line per unset optional config, so 'why doesn't X work on + prod' starts with a log grep instead of guesswork.""" + gaps: list[str] = [] + if IS_DEV_PROFILE: + if not config.SHEET_ID: + gaps.append("SHEET_ID not set - member sync/birthdays have no sheet") + if not config.BIRTHDAY_CHANNEL_ID: + gaps.append("BIRTHDAY_CHANNEL_ID not set - birthday pings disabled") + if not config.PB_ADMIN_EMAIL or not config.PB_ADMIN_PASSWORD: + gaps.append("PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD not set - economy database writes will fail") + if not config.BOT_ADMIN_ROLES: + gaps.append("DISCORD_ADMIN_ROLES not set - role-based bot-admin checks disabled") + for gap in gaps: + log.warning("Config: %s", gap) + if not gaps: + log.info("Config: all expected settings present for profile '%s'", config.BOT_PROFILE) + + def _log_sync_result(member: discord.Member, result: SyncResult): if result.nickname_changed: log.info(" → Nickname set for %s", member) diff --git a/core/economy.py b/core/economy.py deleted file mode 100644 index fee1aea..0000000 --- a/core/economy.py +++ /dev/null @@ -1,2204 +0,0 @@ -"""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 asyncio -import functools -import logging -import math -import random -from datetime import date, datetime, timedelta, timezone -from typing import TypedDict - -import aiohttp - -from . import pb_client -from .pb_client import DatabaseError -from .emoji import EMOJI as E - -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) - - -# Per-profile emoji values live in core/emoji.py; add new IDs there. -COIN = E["TipiCOIN"] -PP_EMOJI = E["TipiFIRE"] -PRESTIGE_ROLE = "TipiPRESTIGE" -PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige - -# --------------------------------------------------------------------------- -# Shop catalogue -# --------------------------------------------------------------------------- -class ShopItem(TypedDict): - name: str - emoji: str - cost: int - description: str - - -SHOP: dict[str, ShopItem] = { - "gaming_hiir": { - "name": "Mängurihiir", - "emoji": E["TipiHIIR"], - "cost": 500, - "description": strings.ITEM_DESCRIPTIONS["gaming_hiir"], - }, - "hiirematt": { - "name": "Hiirematt", - "emoji": E["TipiMATT"], - "cost": 600, - "description": strings.ITEM_DESCRIPTIONS["hiirematt"], - }, - "korvaklapid": { - "name": "K\u00f5rvaklapid", - "emoji": E["TipiKLAPID"], - "cost": 1200, - "description": strings.ITEM_DESCRIPTIONS["korvaklapid"], - }, - "lan_pass": { - "name": "LAN pilet", - "emoji": E["TipiPILET"], - "cost": 1200, - "description": strings.ITEM_DESCRIPTIONS["lan_pass"], - }, - "energiajook": { - "name": "Red Bull", - "emoji": E["TipiBULL"], - "cost": 800, - "description": strings.ITEM_DESCRIPTIONS["energiajook"], - }, - "gaming_laptop": { - "name": "Bot Farm", - "emoji": E["TipiLAP"], - "cost": 1500, - "description": strings.ITEM_DESCRIPTIONS["gaming_laptop"], - }, - "anticheat": { - "name": "Anticheat", - "emoji": E["TipiVAC"], - "cost": 1000, - "description": strings.ITEM_DESCRIPTIONS["anticheat"], - }, - # ----- Tier 2 ----- - "reguleeritav_laud": { - "name": "Reguleeritav laud", - "emoji": E["TipiLAUD"], - "cost": 3500, - "description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"], - }, - "jellyfin": { - "name": "Jellyfin server", - "emoji": E["TipiSERVER"], - "cost": 4000, - "description": strings.ITEM_DESCRIPTIONS["jellyfin"], - }, - "mikrofon": { - "name": "Eraldiseisev mikrofon", - "emoji": E["TipiMIC"], - "cost": 2800, - "description": strings.ITEM_DESCRIPTIONS["mikrofon"], - }, - "klaviatuur": { - "name": "Mehaaniline klaviatuur", - "emoji": E["TipiKLAVA"], - "cost": 1800, - "description": strings.ITEM_DESCRIPTIONS["klaviatuur"], - }, - "monitor": { - "name": "Ultralai monitor", - "emoji": E["TipiMONITOR"], - "cost": 2500, - "description": strings.ITEM_DESCRIPTIONS["monitor"], - }, - "cat6": { - "name": "Cat6 kaabel", - "emoji": E["TipiCAT"], - "cost": 3500, - "description": strings.ITEM_DESCRIPTIONS["cat6"], - }, - # ----- Tier 3 ----- - "monitor_360": { - "name": "360Hz monitor", - "emoji": E["TipiMONITOR2"], - "cost": 7500, - "description": strings.ITEM_DESCRIPTIONS["monitor_360"], - }, - "karikas": { - "name": "TipiLAN karikas", - "emoji": E["TipiKARIKAS"], - "cost": 6000, - "description": strings.ITEM_DESCRIPTIONS["karikas"], - }, - "gaming_tool": { - "name": "Gaming tool", - "emoji": E["TipiTOOL"], - "cost": 9000, - "description": strings.ITEM_DESCRIPTIONS["gaming_tool"], - }, - # ----- Fishing items ----- - "ussipurk": { - "name": "Ussipurk", - "emoji": "🪣", - "cost": 3500, - "description": strings.ITEM_DESCRIPTIONS["ussipurk"], - }, - "kalavork": { - "name": "Kalavõrk", - "emoji": "🪝", - "cost": 5000, - "description": strings.ITEM_DESCRIPTIONS["kalavork"], - }, - "echolood": { - "name": "Echolood", - "emoji": "📡", - "cost": 8000, - "description": strings.ITEM_DESCRIPTIONS["echolood"], - }, -} - -# 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", "ussipurk"], - 3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"], -} - -# 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, - "ussipurk": 10, - "monitor_360": 20, - "karikas": 20, - "gaming_tool": 20, - "kalavork": 20, - "echolood": 20, -} - -# --------------------------------------------------------------------------- -# Prestige shop catalogue -# --------------------------------------------------------------------------- -class PrestigeItem(TypedDict): - emoji: str - max_level: int - pp_cost: int - effect: float - - -PRESTIGE_SHOP: dict[str, PrestigeItem] = { - "coin_mult": { - "emoji": E["TipiCOIN"], - "max_level": 5, - "pp_cost": 5, - "effect": 0.08, - }, - "exp_mult": { - "emoji": "✨", - "max_level": 5, - "pp_cost": 5, - "effect": 0.08, - }, - "daily_plus": { - "emoji": "📅", - "max_level": 3, - "pp_cost": 7, - "effect": 0.20, - }, - "work_plus": { - "emoji": "💼", - "max_level": 3, - "pp_cost": 7, - "effect": 0.20, - }, -} - -# --------------------------------------------------------------------------- -# Fish catalogue -# --------------------------------------------------------------------------- -FISH_CATALOGUE: dict[str, dict] = { - # id: { rarity, weight=(min_g, max_g), coins=(min, max), exp } - "sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3}, - "ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3}, - "koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3}, - "viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2}, - "latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6}, - "karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7}, - "linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6}, - "haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10}, - "angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10}, - "siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10}, - "forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15}, - "koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15}, - "tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14}, - "lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25}, - "vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25}, -} - -FISH_RARITY_WEIGHTS: dict[str, int] = { - "junk": 15, - "common": 45, - "uncommon": 22, - "rare": 12, - "epic": 5, - "legendary": 1, -} - - -def roll_fish(rarity_bump: bool = False) -> tuple[str, int]: - """Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0). - rarity_bump=True (kalavork item) shifts each catch one tier up. - """ - rarity_pool = list(FISH_RARITY_WEIGHTS.keys()) - weights = list(FISH_RARITY_WEIGHTS.values()) - chosen_rarity = random.choices(rarity_pool, weights=weights)[0] - if chosen_rarity == "junk": - return ("junk", 0) - if rarity_bump: - order = ["common", "uncommon", "rare", "epic", "legendary"] - idx = order.index(chosen_rarity) if chosen_rarity in order else 0 - chosen_rarity = order[min(idx + 1, len(order) - 1)] - fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity] - if not fish_of_rarity: - return ("junk", 0) - fish_id = random.choice(fish_of_rarity) - fish = FISH_CATALOGUE[fish_id] - weight = random.randint(fish["weight"][0], fish["weight"][1]) - return (fish_id, weight) - -# --------------------------------------------------------------------------- -# 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. - Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0. - Closed form: 10*level^2.""" - if level <= 1: - return 0 - return 10 * level * level - - -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), - "fish": timedelta(minutes=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 - # Prestige system - prestige_level: int - prestige_points: int - season_total_exp: int # cumulative EXP this season (survives prestige resets) - prestige_upgrades: dict # {upgrade_id: level} - # Fishing system - last_fish: str | None - fish_book: dict # {fish_id: times_caught} - total_fish_caught: int - fish_inventory: list # [{fish_id, weight, value}] - survives prestige - # Quest system - quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}} - quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}} - - -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, - # ── Prestige ───────────────────────────────────────────────────────── - "prestige_level": 0, - "prestige_points": 0, - "season_total_exp": 0, - "prestige_upgrades": {}, - # ── Fishing ────────────────────────────────────────────────────────── - "last_fish": None, - "fish_book": {}, - "total_fish_caught": 0, - "fish_inventory": [], - # ── Quests ─────────────────────────────────────────────────────────── - "quest_daily": {}, - "quest_weekly": {}, - } - - -# --------------------------------------------------------------------------- -# Persistence (PocketBase backend) -# --------------------------------------------------------------------------- -_log = logging.getLogger("tipiCOIN.economy") - -# --------------------------------------------------------------------------- -# Per-user write locks -# --------------------------------------------------------------------------- -# Every mutation is a read-modify-write cycle (get_user → mutate → _commit); -# without serialization, two concurrent commands for the same user overwrite -# each other's commit. Locking rules that keep this deadlock-free: -# - a decorated function must never call another decorated function -# - house balance changes go through _credit_house (an atomic PocketBase -# increment, no lock), so they are safe while holding user locks -_user_locks: dict[int, asyncio.Lock] = {} - - -def _user_lock(user_id: int) -> asyncio.Lock: - lock = _user_locks.get(user_id) - if lock is None: - lock = _user_locks[user_id] = asyncio.Lock() - return lock - - -def _locked_by(*arg_positions: int): - """Serialize the decorated function per user id found at the given - positional-argument indices. Multiple ids are acquired in sorted order so - two-user functions (do_give, do_rob) cannot deadlock each other.""" - def decorator(fn): - @functools.wraps(fn) - async def wrapper(*args, **kwargs): - locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})] - for lock in locks: - await lock.acquire() - try: - return await fn(*args, **kwargs) - finally: - for lock in reversed(locks): - lock.release() - return wrapper - return decorator - - -# --------------------------------------------------------------------------- -# House account (bot user) -# --------------------------------------------------------------------------- -HOUSE_ID: int | None = None -_house_pb_id: str | None = None - - -def set_house(user_id: int) -> None: - """Register the bot's Discord user ID as the house account.""" - global HOUSE_ID, _house_pb_id - if HOUSE_ID != user_id: - _house_pb_id = None - HOUSE_ID = user_id - - -async def _house_record_id() -> str | None: - """PocketBase record id of the house account (cached; creates the record on first use).""" - global _house_pb_id - if HOUSE_ID is None: - return None - if _house_pb_id is None: - house = await get_user(HOUSE_ID) - _house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item] - return _house_pb_id - - -async def _credit_house(amount: int) -> None: - """Add `amount` coins to the house via an atomic PocketBase increment. - - Deliberately lock-free: callers hold per-user locks, so this must never - acquire one itself (see the locking rules above).""" - if amount <= 0: - return - record_id = await _house_record_id() - if record_id is None: - return - await pb_client.update_record(record_id, {"balance+": amount}) - - -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.""" - record_id = await _house_record_id() - if record_id is None: - return - await pb_client.update_record(record_id, {"heist_global_cd_until": until}) - - -@_locked_by(0) -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 missing_schema_fields() -> list[str]: - """Compare the live PocketBase collection schema against every field the - bot persists. PocketBase silently drops writes to undeclared fields, so - any name returned here means broken features without error messages.""" - live = await pb_client.get_collection_fields() - expected = set(_default_user()) | {"user_id"} - return sorted(expected - live) - - -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 - - -# --------------------------------------------------------------------------- -# 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) - try: - 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) - except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: - _log.error("PocketBase unreachable for user %s: %s", user_id, exc) - raise DatabaseError(f"Database unavailable: {exc}") from exc - 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] - - -def _prestige_mult(user: UserData) -> tuple[float, float]: - """Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0.""" - upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment] - coin_level = upgrades.get("coin_mult", 0) - exp_level = upgrades.get("exp_mult", 0) - return ( - 1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"], - 1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"], - ) - - -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] - - -@_locked_by(0) -async def award_exp(user_id: int, amount: int) -> dict: - """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp.""" - user = await get_user(user_id) - _, exp_mult = _prestige_mult(user) - gained = max(1, int(amount * exp_mult)) - old_exp = user.get("exp", 0) - new_exp = old_exp + gained - old_level = get_level(old_exp) - new_level = get_level(new_exp) - user["exp"] = new_exp - user["season_total_exp"] = user.get("season_total_exp", 0) + gained - await _commit(user_id, user) - return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained} - - -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, - "last_fish": None, - "daily_streak": 0, - "last_streak_date": None, - "season_total_exp": 0, - } - 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) -> dict | None: - """Persist the full user record. Returns the record as PocketBase stored it - (fields absent from the collection schema are silently dropped by PB).""" - 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) - try: - if record_id: - return 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] - return created - except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: - _log.error("_commit failed for user %s: %s", user_id, exc) - raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc - - -# --------------------------------------------------------------------------- -# Quest system -# --------------------------------------------------------------------------- -# Quests reuse the monotonic lifetime counters already tracked on each user. -# Progress = (current counter value) - (snapshot taken when the quest was rolled). -# Reset is lazy & per-user: the active set is regenerated the first time a user -# interacts after the day / ISO-week rolls over (mirrors the streak-date logic). -# Rotation is seeded by user id + period key, so each player gets their own set. -class QuestDef(TypedDict): - stat: str # UserData counter field the quest tracks - goal: int - coins: int - exp: int - - -QUESTS_DAILY: dict[str, QuestDef] = { - "work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20}, - "beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15}, - "wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20}, - "fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20}, - "crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25}, - "earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20}, - "give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15}, -} - -QUESTS_WEEKLY: dict[str, QuestDef] = { - "work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100}, - "fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100}, - "wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100}, - "crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120}, - "heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80}, - "earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150}, -} - -DAILY_QUEST_COUNT = 3 -WEEKLY_QUEST_COUNT = 2 - - -def _period_keys() -> tuple[str, str]: - """Return (day_key, week_key) for the current UTC time.""" - today = _now().date() - iso = today.isocalendar() - return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}" - - -def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]: - """Deterministically choose `count` quest ids from `pool` for a period.""" - rng = random.Random(seed) - return rng.sample(sorted(pool.keys()), min(count, len(pool))) - - -def _new_quest_block( - user: UserData, user_id: int, pool: dict[str, QuestDef], count: int, - period_val: str, period_field: str -) -> dict: - chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}") - return { - period_field: period_val, - "quests": { - qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False} - for qid in chosen - }, - } - - -def _ensure_quests(user: UserData, user_id: int) -> bool: - """Roll fresh daily/weekly quest sets if their period elapsed. - Mutates `user` in place; returns True if anything changed (caller commits).""" - changed = False - day_key, week_key = _period_keys() - if (user.get("quest_daily") or {}).get("date") != day_key: - user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date") - changed = True - if (user.get("quest_weekly") or {}).get("week") != week_key: - user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week") - changed = True - return changed - - -def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int: - cur = int(user.get(pool[qid]["stat"], 0) or 0) - return max(0, cur - int(state.get("snap", 0))) - - -def _quest_view(user: UserData) -> dict: - def build(pool: dict[str, QuestDef], block: dict) -> list[dict]: - out: list[dict] = [] - for qid, state in (block.get("quests") or {}).items(): - if qid not in pool: - continue - d = pool[qid] - prog = min(d["goal"], _quest_progress(user, pool, qid, state)) - out.append({ - "id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"], - "progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")), - }) - return out - return { - "daily": build(QUESTS_DAILY, user.get("quest_daily") or {}), - "weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}), - } - - -@_locked_by(0) -async def get_quests(user_id: int) -> dict: - """Return the user's active quests, rolling new sets if the period elapsed.""" - user = await get_user(user_id) - if _ensure_quests(user, user_id): - saved = await _commit(user_id, user) - if saved is not None and "quest_daily" not in saved: - _log.warning( - "PocketBase collection has no quest fields - quest state is not " - "persisted and progress will stay at 0. Run scripts/add_quest_fields.py." - ) - return _quest_view(user) - - -@_locked_by(0) -async def claim_quests(user_id: int) -> dict: - """Grant coins for every completed-but-unclaimed quest and mark them claimed. - Coins (with prestige coin_mult) are paid here; EXP is returned raw for the - caller to award via the shared award_exp path (keeps level-up notices).""" - user = await get_user(user_id) - _ensure_quests(user, user_id) - coin_mult, _ = _prestige_mult(user) - total_coins = total_exp = claimed = 0 - for pool, block in ( - (QUESTS_DAILY, user.get("quest_daily") or {}), - (QUESTS_WEEKLY, user.get("quest_weekly") or {}), - ): - for qid, state in (block.get("quests") or {}).items(): - if qid not in pool or state.get("claimed"): - continue - if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]: - continue - total_coins += pool[qid]["coins"] - total_exp += pool[qid]["exp"] - state["claimed"] = True - claimed += 1 - if not claimed: - return {"ok": False, "reason": "nothing"} - coins_awarded = int(total_coins * coin_mult) - user["balance"] += coins_awarded - user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded - user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) - await _commit(user_id, user) - return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]} - - -# --------------------------------------------------------------------------- -# /daily -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_daily(user_id: int) -> dict: - 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"} - - 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 - - daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0) - base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"])) - earned = int(base * streak_mult * vip_mult) - if "korvaklapid" in user["items"]: - earned += 25 - coin_mult, _ = _prestige_mult(user) - earned = int(earned * coin_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 - - -@_locked_by(0) -async def do_work(user_id: int) -> dict: - 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"} - - 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 - - work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0) - work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"] - coin_mult, _ = _prestige_mult(user) - earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult) - 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 - - -@_locked_by(0) -async def do_beg(user_id: int) -> dict: - 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"} - - 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 - coin_mult, _ = _prestige_mult(user) - earned = int(random.randint(10, 40) * beg_mult * coin_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"], - } - - -# --------------------------------------------------------------------------- -# /fish -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_fish_start(user_id: int) -> dict: - """Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" - 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"} - if jail := _is_jailed(user): - return {"ok": False, "reason": "jailed", "remaining": jail} - - fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"] - if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd): - return {"ok": False, "reason": "cooldown", "remaining": cd} - - user["last_fish"] = _now().isoformat() - await _commit(user_id, user) - return {"ok": True} - - -@_locked_by(0) -async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict: - """Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value.""" - user = await get_user(user_id) - - if fish_id == "junk": - _txn("FISH_JUNK", user=user_id) - return {"ok": True, "type": "junk", "coins": 0, "exp": 0} - - if fish_id not in FISH_CATALOGUE: - return {"ok": False, "reason": "invalid_fish"} - - fish = FISH_CATALOGUE[fish_id] - min_c, max_c = fish["coins"] - w_min, w_max = fish["weight"] - weight_ratio = (weight - w_min) / max(1, w_max - w_min) - base_coins = int(min_c + weight_ratio * (max_c - min_c)) - coin_mult, _ = _prestige_mult(user) - value = int(base_coins * coin_mult) - exp = fish["exp"] - - book: dict = user.get("fish_book") or {} - prev_count = book.get(fish_id, 0) - book[fish_id] = prev_count + 1 - user["fish_book"] = book - user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1 - - inv: list = list(user.get("fish_inventory") or []) - inv.append({"fish_id": fish_id, "weight": weight, "value": value}) - user["fish_inventory"] = inv - - await _commit(user_id, user) - _txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value) - - return { - "ok": True, - "type": "fish", - "fish_id": fish_id, - "weight": weight, - "value": value, - "exp": exp, - "is_new": prev_count == 0, - "total_caught": book[fish_id], - } - - -@_locked_by(0) -async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict: - """Sell fish from inventory. indices=None sells all. Returns coins earned.""" - user = await get_user(user_id) - inv: list = list(user.get("fish_inventory") or []) - if not inv: - return {"ok": False, "reason": "empty"} - - if indices is None: - to_sell = inv - remaining = [] - else: - sell_idx = { - (i if i >= 0 else len(inv) + i) - for i in indices - } - sell_idx = {i for i in sell_idx if 0 <= i < len(inv)} - to_sell = [inv[i] for i in sorted(sell_idx)] - keep_idx = set(range(len(inv))) - sell_idx - remaining = [inv[i] for i in sorted(keep_idx)] - - if not to_sell: - return {"ok": False, "reason": "empty"} - - total_coins = sum(entry["value"] for entry in to_sell) - user["fish_inventory"] = remaining - user["balance"] = user.get("balance", 0) + total_coins - user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins - user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) - await _commit(user_id, user) - _txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"]) - return { - "ok": True, - "coins": total_coins, - "count": len(to_sell), - "balance": user["balance"], - } - - -async def do_fishbook(user_id: int) -> dict: - """Return the user's fish book data including per-species inventory counts.""" - user = await get_user(user_id) - book: dict = user.get("fish_book") or {} - inv: list = user.get("fish_inventory") or [] - inv_counts: dict[str, int] = {} - for entry in inv: - fid = entry.get("fish_id", "") - inv_counts[fid] = inv_counts.get(fid, 0) + 1 - return { - "ok": True, - "book": book, - "inv_counts": inv_counts, - "total_fish_caught": user.get("total_fish_caught", 0), - "unique_caught": len(book), - "total_species": len(FISH_CATALOGUE), - } - - -# --------------------------------------------------------------------------- -# /prestige -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_prestige(user_id: int) -> dict: - """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" - 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"} - - exp = user.get("exp", 0) - level = get_level(exp) - if level < PRESTIGE_MIN_LEVEL: - return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL} - - pp_earned = max(1, exp // 1000) - new_prestige_level = user.get("prestige_level", 0) + 1 - - # Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades - user["balance"] = 0 - user["exp"] = 0 - user["items"] = [] - user["item_uses"] = {} - user["last_daily"] = None - user["last_work"] = None - user["last_beg"] = None - user["last_crime"] = None - user["last_rob"] = None - user["last_fish"] = None - user["last_heist"] = None - user["daily_streak"] = 0 - user["last_streak_date"] = None - user["jailed_until"] = None - user["jailbreak_used"] = False - user["prestige_level"] = new_prestige_level - user["prestige_points"] = user.get("prestige_points", 0) + pp_earned - - await _commit(user_id, user) - _txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp) - - return { - "ok": True, - "pp_earned": pp_earned, - "prestige_level": new_prestige_level, - "prestige_points": user["prestige_points"], - "old_exp": exp, - } - - -@_locked_by(0) -async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict: - """Spend PP to buy a prestige upgrade level.""" - if upgrade_id not in PRESTIGE_SHOP: - return {"ok": False, "reason": "not_found"} - - 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"} - - upgrade = PRESTIGE_SHOP[upgrade_id] - upgrades: dict = user.get("prestige_upgrades") or {} - current_level = upgrades.get(upgrade_id, 0) - - if current_level >= upgrade["max_level"]: - return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]} - - pp = user.get("prestige_points", 0) - cost = upgrade["pp_cost"] - if pp < cost: - return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost} - - upgrades[upgrade_id] = current_level + 1 - user["prestige_upgrades"] = upgrades - user["prestige_points"] = pp - cost - - await _commit(user_id, user) - _txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id, - new_level=upgrades[upgrade_id], pp_left=user["prestige_points"]) - - return { - "ok": True, - "upgrade_id": upgrade_id, - "new_level": upgrades[upgrade_id], - "max_level": upgrade["max_level"], - "pp_remaining": user["prestige_points"], - } - - -# --------------------------------------------------------------------------- -# Extended leaderboards -# --------------------------------------------------------------------------- -async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]: - """Return (user_id, season_total_exp, prestige_level) sorted by season EXP.""" - records = await pb_client.list_all_records() - result = sorted( - ( - (r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 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_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]: - """Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP.""" - records = await pb_client.list_all_records() - result = sorted( - ( - (r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0)) - for r in records if r.get("user_id") - ), - key=lambda x: (x[1], x[2]), - reverse=True, - ) - return result if top_n is None else result[:top_n] - - -async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]: - """Return (user_id, total_wagered) sorted descending.""" - records = await pb_client.list_all_records() - result = sorted( - ((r["user_id"], r.get("total_wagered", 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_fish(top_n: int | None = 10) -> list[tuple[str, int]]: - """Return (user_id, total_fish_caught) sorted descending.""" - records = await pb_client.list_all_records() - result = sorted( - ((r["user_id"], r.get("total_fish_caught", 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] - - -# --------------------------------------------------------------------------- -# /crime -# --------------------------------------------------------------------------- -_CRIME_WIN = strings.CRIME_WIN -_CRIME_LOSE = strings.CRIME_LOSE - - -@_locked_by(0) -async def do_crime(user_id: int) -> dict: - 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"} - - 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) -# --------------------------------------------------------------------------- -@_locked_by(0) -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) - - -@_locked_by(0) -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 - -@_locked_by(0) -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 -# --------------------------------------------------------------------------- -@_locked_by(0, 1) -async def do_rob(robber_id: int, target_id: int) -> dict: - try: - robber = await get_user(robber_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - if robber.get("eco_banned"): - return {"ok": False, "reason": "banned"} - try: - target = await get_user(target_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - - 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 - prev_lifetime_earned = robber.get("lifetime_earned", 0) - prev_biggest_win = robber.get("biggest_win", 0) - prev_peak_balance = robber.get("peak_balance", 0) - robber["balance"] += stolen - robber["lifetime_earned"] = prev_lifetime_earned + stolen - robber["biggest_win"] = max(prev_biggest_win, stolen) - robber["peak_balance"] = max(prev_peak_balance, robber["balance"]) - try: - await _commit(robber_id, robber) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - try: - await _commit(target_id, target) - except DatabaseError: - robber["balance"] -= stolen - robber["lifetime_earned"] = prev_lifetime_earned - robber["biggest_win"] = prev_biggest_win - robber["peak_balance"] = prev_peak_balance - try: - await _commit(robber_id, robber) - except DatabaseError as exc2: - _log.critical( - "do_rob rollback failed for robber %s after target commit failed: %s", - robber_id, exc2, - ) - return {"ok": False, "reason": "db_error"} - _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 -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_roulette(user_id: int, bet: int, colour: str) -> dict: - 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"} - 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) -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: - """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" - 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"} - 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"]} - - -# --------------------------------------------------------------------------- -# /rps PvP escrow (deposit/payout/refund) -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: - """Hold `bet` coins from a player as escrow for a PvP RPS duel.""" - 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"} - if jail := _is_jailed(user): - return {"ok": False, "reason": "jailed", "remaining": jail} - if user["balance"] < bet: - return {"ok": False, "reason": "insufficient"} - user["balance"] -= bet - user["total_wagered"] = user.get("total_wagered", 0) + bet - try: - await _commit(user_id, user) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - _txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"]) - return {"ok": True, "balance": user["balance"]} - - -@_locked_by(0) -async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: - """Credit the duel winner with 2*bet (their stake back + opponent's).""" - try: - user = await get_user(winner_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - payout = bet * 2 - user["balance"] = user.get("balance", 0) + payout - 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"]) - try: - await _commit(winner_id, user) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - _txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"]) - return {"ok": True, "balance": user["balance"]} - - -@_locked_by(0) -async def do_rps_pvp_refund(user_id: int, bet: int) -> dict: - """Refund a previously escrowed bet (tie / timeout / cancel).""" - try: - user = await get_user(user_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - user["balance"] = user.get("balance", 0) + bet - user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet) - try: - await _commit(user_id, user) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - _txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"]) - return {"ok": True, "balance": user["balance"]} - - -# --------------------------------------------------------------------------- -# /slots -# --------------------------------------------------------------------------- -_SLOTS_SYMBOLS: list[tuple[str, int]] = [ - (E["TipiHEART"], 27), - (E["TipiFIRE"], 22), - (E["TipiTROLL"], 18), - (E["TipICRY"], 15), - (E["TipiSKULL"], 10), - (E["TipiKARIKAS"], 8), -] -_SLOTS_JACKPOT = E["TipiKARIKAS"] -_SLOTS_TRIPLE_MULT: dict[str, int] = { - E["TipiHEART"]: 4, - E["TipiFIRE"]: 5, - E["TipiTROLL"]: 7, - E["TipICRY"]: 10, - E["TipiSKULL"]: 15, - E["TipiKARIKAS"]: 25, # jackpot -} - - -def _spin() -> str: - symbols, weights = zip(*_SLOTS_SYMBOLS) - return random.choices(list(symbols), weights=list(weights), k=1)[0] - - -@_locked_by(0) -async def do_slots(user_id: int, bet: int) -> dict: - 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"} - 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 -# --------------------------------------------------------------------------- -@_locked_by(0, 1) -async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: - try: - giver = await get_user(giver_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - 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"} - - try: - receiver = await get_user(receiver_id) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - giver["balance"] -= amount - receiver["balance"] += amount - giver["total_given"] = giver.get("total_given", 0) + amount - receiver["total_received"] = receiver.get("total_received", 0) + amount - try: - await _commit(giver_id, giver) - except DatabaseError: - return {"ok": False, "reason": "db_error"} - try: - await _commit(receiver_id, receiver) - except DatabaseError: - giver["balance"] += amount - giver["total_given"] = max(0, giver.get("total_given", 0) - amount) - try: - await _commit(giver_id, giver) - except DatabaseError as exc2: - _log.critical( - "do_give rollback failed for giver %s after receiver commit failed: %s", - giver_id, exc2, - ) - return {"ok": False, "reason": "db_error"} - _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 -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_buy(user_id: int, item_id: str) -> dict: - if item_id not in SHOP: - return {"ok": False, "reason": "not_found"} - 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"} - - 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 -# --------------------------------------------------------------------------- -@_locked_by(0) -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} - - -@_locked_by(0) -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"]} - - -@_locked_by(0) -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} - - -@_locked_by(0) -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} - - -@_locked_by(0) -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} - - -@_locked_by(0) -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)} - - -@_locked_by(0) -async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict: - """Give (positive) or take (negative) EXP from a user. EXP is floored at 0.""" - user = await get_user(target_id) - old_exp = user.get("exp", 0) - old_level = get_level(old_exp) - user["exp"] = max(0, old_exp + amount) - user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount) - new_level = get_level(user["exp"]) - await _commit(target_id, user) - verb = f"+{amount}" if amount >= 0 else str(amount) - _txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"]) - return { - "ok": True, - "exp": user["exp"], - "change": amount, - "old_level": old_level, - "new_level": new_level, - "level_changed": new_level != old_level, - } - - -@_locked_by(0) -async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict: - """Give or remove an item. action='give'|'remove'. Returns ok/reason.""" - if item_id not in SHOP: - return {"ok": False, "reason": "invalid_item"} - user = await get_user(target_id) - items: list = list(user.get("items") or []) - item_uses: dict = dict(user.get("item_uses") or {}) - if action == "give": - if item_id not in items: - items.append(item_id) - if item_id == "anticheat": - item_uses["anticheat"] = 2 - user["items"] = items - user["item_uses"] = item_uses - await _commit(target_id, user) - _txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id) - return {"ok": True, "action": "given", "item_id": item_id} - elif action == "remove": - if item_id not in items: - return {"ok": False, "reason": "not_owned"} - items.remove(item_id) - item_uses.pop(item_id, None) - user["items"] = items - user["item_uses"] = item_uses - await _commit(target_id, user) - _txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id) - return {"ok": True, "action": "removed", "item_id": item_id} - return {"ok": False, "reason": "invalid_action"} - - -# --------------------------------------------------------------------------- -# /reminders -# --------------------------------------------------------------------------- -@_locked_by(0) -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 -# --------------------------------------------------------------------------- -@_locked_by(0) -async def do_blackjack_bet(user_id: int, bet: int) -> dict: - """Deduct the initial blackjack bet. Returns ok/fail.""" - 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"} - 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"]} - - -@_locked_by(0) -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.""" - 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"} - 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. - - Per-user commit failures attempt to compensate the house so the economy - stays balanced. If compensation also fails, a CRITICAL log is emitted. - """ - now = _now() - payout_each = 0 - failed_users: list[int] = [] - - if success and HOUSE_ID is not None: - try: - 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) - # Atomic decrement (capped at the balance we read) instead of a full - # record commit, so concurrent _credit_house increments aren't lost. - debit = min(total, house["balance"]) - if debit > 0: - await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] - except DatabaseError: - return {"ok": False, "reason": "db_error"} - _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) - - for uid in user_ids: - async with _user_lock(uid): - try: - user = await get_user(uid) - except DatabaseError: - failed_users.append(uid) - if success and payout_each > 0: - await _refund_house_safe(payout_each, "heist_win_compensate", uid) - continue - user["last_heist"] = now.isoformat() - user["heists_joined"] = user.get("heists_joined", 0) + 1 - fine_credited = False - 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: - try: - await _credit_house(fine) - fine_credited = True - except DatabaseError: - pass # user commit will still be attempted; if both fail, no economy effect - try: - await _commit(uid, user) - except DatabaseError: - failed_users.append(uid) - if success and payout_each > 0: - await _refund_house_safe(payout_each, "heist_win_compensate", uid) - elif not success and fine_credited: - await _refund_user_safe(HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) - - return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users} - - -async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None: - """Best-effort refund of `amount` to the house. Logs critical if it fails.""" - if HOUSE_ID is None or amount <= 0: - return - try: - await _credit_house(amount) - except DatabaseError as exc: - _log.critical( - "House compensation failed (%s, related uid %s, amount %s): %s", - context, related_uid, amount, exc, - ) - - -async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None: - """Best-effort atomic debit of `amount` from the house (compensates a failed - user fine). Logs critical if it fails.""" - if HOUSE_ID is None or amount <= 0: - return - try: - record_id = await _house_record_id() - if record_id: - await pb_client.update_record(record_id, {"balance-": amount}) - except DatabaseError as exc: - _log.critical( - "House debit compensation failed (%s, related uid %s, amount %s): %s", - context, uid, amount, exc, - ) diff --git a/core/economy/__init__.py b/core/economy/__init__.py new file mode 100644 index 0000000..eb47512 --- /dev/null +++ b/core/economy/__init__.py @@ -0,0 +1,35 @@ +"""TipiCOIN economy - data layer and business logic. + +Storage: PocketBase (see core/pb_client.py). All public async functions are the +single source of truth for mutations; see store.py for the locking rules. + +This package re-exports everything so callers keep using `from core import +economy` + attribute access. +""" + +from ..pb_client import DatabaseError + +from .store import * +from .store import ( + _commit, _default_user, _is_jailed, _locked_by, _now, _parse_dt, + _prestige_mult, _txn, _user_lock, _user_locks, +) +from .house import * +from .house import _credit_house, _house_record_id +from .levels import * +from .shop import * +from .fishing import * +from .quests import * +from .quests import _ensure_quests, _pick_quests, _quest_view +from .income import * +from .jail import * +from .gambling import * +from .prestige import * +from .leaderboards import * +from .heist import * +from .admin import * + +from . import ( # noqa: E402 (submodules addressable as economy.store etc.) + admin, fishing, gambling, heist, house, income, jail, leaderboards, + levels, prestige, quests, shop, store, +) diff --git a/core/economy/admin.py b/core/economy/admin.py new file mode 100644 index 0000000..ffe3db9 --- /dev/null +++ b/core/economy/admin.py @@ -0,0 +1,165 @@ +"""Admin mutations and the season reset.""" + +from __future__ import annotations + +from datetime import timedelta + +from .. import pb_client +from .store import _commit, _default_user, _locked_by, _now, _txn, get_user +from .levels import get_level +from .shop import SHOP + + +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, + "last_fish": None, + "daily_streak": 0, + "last_streak_date": None, + "season_total_exp": 0, + } + for record in records: + await pb_client.update_record(record["id"], reset_fields) + return [(uid, exp, get_level(exp)) for uid, exp in top] + + +# --------------------------------------------------------------------------- +# Admin actions +# --------------------------------------------------------------------------- +@_locked_by(0) +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} + + +@_locked_by(0) +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"]} + + +@_locked_by(0) +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} + + +@_locked_by(0) +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} + + +@_locked_by(0) +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} + + +@_locked_by(0) +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)} + + +@_locked_by(0) +async def do_admin_exp(target_id: int, amount: int, admin_id: int, reason: str) -> dict: + """Give (positive) or take (negative) EXP from a user. EXP is floored at 0.""" + user = await get_user(target_id) + old_exp = user.get("exp", 0) + old_level = get_level(old_exp) + user["exp"] = max(0, old_exp + amount) + user["season_total_exp"] = max(0, user.get("season_total_exp", 0) + amount) + new_level = get_level(user["exp"]) + await _commit(target_id, user) + verb = f"+{amount}" if amount >= 0 else str(amount) + _txn("ADMIN_EXP", admin=admin_id, target=target_id, amount=verb, reason=reason, exp=user["exp"]) + return { + "ok": True, + "exp": user["exp"], + "change": amount, + "old_level": old_level, + "new_level": new_level, + "level_changed": new_level != old_level, + } + + +@_locked_by(0) +async def do_admin_item(target_id: int, item_id: str, action: str, admin_id: int) -> dict: + """Give or remove an item. action='give'|'remove'. Returns ok/reason.""" + if item_id not in SHOP: + return {"ok": False, "reason": "invalid_item"} + user = await get_user(target_id) + items: list = list(user.get("items") or []) + item_uses: dict = dict(user.get("item_uses") or {}) + if action == "give": + if item_id not in items: + items.append(item_id) + if item_id == "anticheat": + item_uses["anticheat"] = 2 + user["items"] = items + user["item_uses"] = item_uses + await _commit(target_id, user) + _txn("ADMIN_ITEM_GIVE", admin=admin_id, target=target_id, item=item_id) + return {"ok": True, "action": "given", "item_id": item_id} + elif action == "remove": + if item_id not in items: + return {"ok": False, "reason": "not_owned"} + items.remove(item_id) + item_uses.pop(item_id, None) + user["items"] = items + user["item_uses"] = item_uses + await _commit(target_id, user) + _txn("ADMIN_ITEM_REMOVE", admin=admin_id, target=target_id, item=item_id) + return {"ok": True, "action": "removed", "item_id": item_id} + return {"ok": False, "reason": "invalid_action"} diff --git a/core/economy/fishing.py b/core/economy/fishing.py new file mode 100644 index 0000000..970a54a --- /dev/null +++ b/core/economy/fishing.py @@ -0,0 +1,194 @@ +"""Fishing minigame: catalogue, rolls, catch/sell flows.""" + +from __future__ import annotations + +import random +from datetime import timedelta + +from ..pb_client import DatabaseError +from .store import ( + COOLDOWNS, _cooldown_remaining, _commit, _is_jailed, _locked_by, _now, + _prestige_mult, _txn, get_user, +) + + +# --------------------------------------------------------------------------- +# Fish catalogue +# --------------------------------------------------------------------------- +FISH_CATALOGUE: dict[str, dict] = { + # id: { rarity, weight=(min_g, max_g), coins=(min, max), exp } + "sarj": {"rarity": "common", "weight": (50, 500), "coins": (3, 18), "exp": 3}, + "ahven": {"rarity": "common", "weight": (80, 700), "coins": (5, 22), "exp": 3}, + "koger": {"rarity": "common", "weight": (100, 800), "coins": (5, 20), "exp": 3}, + "viidikas": {"rarity": "common", "weight": (10, 120), "coins": (2, 8), "exp": 2}, + "latikas": {"rarity": "uncommon", "weight": (300, 2500), "coins": (20, 70), "exp": 6}, + "karpkala": {"rarity": "uncommon", "weight": (500, 4000), "coins": (25, 80), "exp": 7}, + "linask": {"rarity": "uncommon", "weight": (200, 2000), "coins": (18, 60), "exp": 6}, + "haug": {"rarity": "rare", "weight": (500, 6000), "coins": (50, 180), "exp": 10}, + "angerjas": {"rarity": "rare", "weight": (200, 1800), "coins": (40, 120), "exp": 10}, + "siig": {"rarity": "rare", "weight": (200, 2000), "coins": (45, 130), "exp": 10}, + "forell": {"rarity": "epic", "weight": (400, 4500), "coins": (100, 280), "exp": 15}, + "koha": {"rarity": "epic", "weight": (600, 7000), "coins": (120, 300), "exp": 15}, + "tougjas": {"rarity": "epic", "weight": (400, 4000), "coins": (90, 250), "exp": 14}, + "lohe": {"rarity": "legendary","weight": (1500, 12000), "coins": (250, 700), "exp": 25}, + "vimb": {"rarity": "legendary","weight": (200, 1200), "coins": (200, 600), "exp": 25}, +} + +FISH_RARITY_WEIGHTS: dict[str, int] = { + "junk": 15, + "common": 45, + "uncommon": 22, + "rare": 12, + "epic": 5, + "legendary": 1, +} + + +def roll_fish(rarity_bump: bool = False) -> tuple[str, int]: + """Roll a random fish. Returns (fish_id, weight_grams) or ('junk', 0). + rarity_bump=True (kalavork item) shifts each catch one tier up. + """ + rarity_pool = list(FISH_RARITY_WEIGHTS.keys()) + weights = list(FISH_RARITY_WEIGHTS.values()) + chosen_rarity = random.choices(rarity_pool, weights=weights)[0] + if chosen_rarity == "junk": + return ("junk", 0) + if rarity_bump: + order = ["common", "uncommon", "rare", "epic", "legendary"] + idx = order.index(chosen_rarity) if chosen_rarity in order else 0 + chosen_rarity = order[min(idx + 1, len(order) - 1)] + fish_of_rarity = [k for k, v in FISH_CATALOGUE.items() if v["rarity"] == chosen_rarity] + if not fish_of_rarity: + return ("junk", 0) + fish_id = random.choice(fish_of_rarity) + fish = FISH_CATALOGUE[fish_id] + weight = random.randint(fish["weight"][0], fish["weight"][1]) + return (fish_id, weight) + + +# --------------------------------------------------------------------------- +# /fish +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_fish_start(user_id: int) -> dict: + """Check cooldown + jail, set cooldown. Call before starting the fishing minigame.""" + 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"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + + fish_cd = timedelta(seconds=90) if "ussipurk" in user["items"] else COOLDOWNS["fish"] + if cd := _cooldown_remaining(user, "fish", override_cd=fish_cd): + return {"ok": False, "reason": "cooldown", "remaining": cd} + + user["last_fish"] = _now().isoformat() + await _commit(user_id, user) + return {"ok": True} + + +@_locked_by(0) +async def do_fish_resolve(user_id: int, fish_id: str, weight: int) -> dict: + """Add catch to inventory + update fish_book. Returns catch info incl. pre-calculated value.""" + user = await get_user(user_id) + + if fish_id == "junk": + _txn("FISH_JUNK", user=user_id) + return {"ok": True, "type": "junk", "coins": 0, "exp": 0} + + if fish_id not in FISH_CATALOGUE: + return {"ok": False, "reason": "invalid_fish"} + + fish = FISH_CATALOGUE[fish_id] + min_c, max_c = fish["coins"] + w_min, w_max = fish["weight"] + weight_ratio = (weight - w_min) / max(1, w_max - w_min) + base_coins = int(min_c + weight_ratio * (max_c - min_c)) + coin_mult, _ = _prestige_mult(user) + value = int(base_coins * coin_mult) + exp = fish["exp"] + + book: dict = user.get("fish_book") or {} + prev_count = book.get(fish_id, 0) + book[fish_id] = prev_count + 1 + user["fish_book"] = book + user["total_fish_caught"] = user.get("total_fish_caught", 0) + 1 + + inv: list = list(user.get("fish_inventory") or []) + inv.append({"fish_id": fish_id, "weight": weight, "value": value}) + user["fish_inventory"] = inv + + await _commit(user_id, user) + _txn("FISH", user=user_id, fish=fish_id, weight=weight, value=value) + + return { + "ok": True, + "type": "fish", + "fish_id": fish_id, + "weight": weight, + "value": value, + "exp": exp, + "is_new": prev_count == 0, + "total_caught": book[fish_id], + } + + +@_locked_by(0) +async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict: + """Sell fish from inventory. indices=None sells all. Returns coins earned.""" + user = await get_user(user_id) + inv: list = list(user.get("fish_inventory") or []) + if not inv: + return {"ok": False, "reason": "empty"} + + if indices is None: + to_sell = inv + remaining = [] + else: + sell_idx = { + (i if i >= 0 else len(inv) + i) + for i in indices + } + sell_idx = {i for i in sell_idx if 0 <= i < len(inv)} + to_sell = [inv[i] for i in sorted(sell_idx)] + keep_idx = set(range(len(inv))) - sell_idx + remaining = [inv[i] for i in sorted(keep_idx)] + + if not to_sell: + return {"ok": False, "reason": "empty"} + + total_coins = sum(entry["value"] for entry in to_sell) + user["fish_inventory"] = remaining + user["balance"] = user.get("balance", 0) + total_coins + user["lifetime_earned"] = user.get("lifetime_earned", 0) + total_coins + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + _txn("FISH_SELL", user=user_id, count=len(to_sell), coins=f"+{total_coins}", bal=user["balance"]) + return { + "ok": True, + "coins": total_coins, + "count": len(to_sell), + "balance": user["balance"], + } + + +async def do_fishbook(user_id: int) -> dict: + """Return the user's fish book data including per-species inventory counts.""" + user = await get_user(user_id) + book: dict = user.get("fish_book") or {} + inv: list = user.get("fish_inventory") or [] + inv_counts: dict[str, int] = {} + for entry in inv: + fid = entry.get("fish_id", "") + inv_counts[fid] = inv_counts.get(fid, 0) + 1 + return { + "ok": True, + "book": book, + "inv_counts": inv_counts, + "total_fish_caught": user.get("total_fish_caught", 0), + "unique_caught": len(book), + "total_species": len(FISH_CATALOGUE), + } diff --git a/core/economy/gambling.py b/core/economy/gambling.py new file mode 100644 index 0000000..b0dc6f3 --- /dev/null +++ b/core/economy/gambling.py @@ -0,0 +1,275 @@ +"""Casino games: roulette, slots, RPS bets and escrow, blackjack.""" + +from __future__ import annotations + +import random + +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E +from .store import _commit, _is_jailed, _locked_by, _txn, get_user +from .house import _credit_house + + +# --------------------------------------------------------------------------- +# /roulette +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_roulette(user_id: int, bet: int, colour: str) -> dict: + 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"} + 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) +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_game_bet(user_id: int, bet: int, outcome: str) -> dict: + """Settle a simple win/tie/lose bet. outcome: 'win' | 'tie' | 'lose'.""" + 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"} + 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"]} + + +# --------------------------------------------------------------------------- +# /rps PvP escrow (deposit/payout/refund) +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_rps_pvp_deposit(user_id: int, bet: int) -> dict: + """Hold `bet` coins from a player as escrow for a PvP RPS duel.""" + 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"} + if jail := _is_jailed(user): + return {"ok": False, "reason": "jailed", "remaining": jail} + if user["balance"] < bet: + return {"ok": False, "reason": "insufficient"} + user["balance"] -= bet + user["total_wagered"] = user.get("total_wagered", 0) + bet + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("RPS_PVP_DEPOSIT", user=user_id, bet=bet, bal=user["balance"]) + return {"ok": True, "balance": user["balance"]} + + +@_locked_by(0) +async def do_rps_pvp_payout(winner_id: int, bet: int) -> dict: + """Credit the duel winner with 2*bet (their stake back + opponent's).""" + try: + user = await get_user(winner_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + payout = bet * 2 + user["balance"] = user.get("balance", 0) + payout + 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"]) + try: + await _commit(winner_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("RPS_PVP_PAYOUT", user=winner_id, payout=f"+{payout}", bal=user["balance"]) + return {"ok": True, "balance": user["balance"]} + + +@_locked_by(0) +async def do_rps_pvp_refund(user_id: int, bet: int) -> dict: + """Refund a previously escrowed bet (tie / timeout / cancel).""" + try: + user = await get_user(user_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + user["balance"] = user.get("balance", 0) + bet + user["total_wagered"] = max(0, user.get("total_wagered", 0) - bet) + try: + await _commit(user_id, user) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("RPS_PVP_REFUND", user=user_id, bet=bet, bal=user["balance"]) + return {"ok": True, "balance": user["balance"]} + + +# --------------------------------------------------------------------------- +# /slots +# --------------------------------------------------------------------------- +_SLOTS_SYMBOLS: list[tuple[str, int]] = [ + (E["TipiHEART"], 27), + (E["TipiFIRE"], 22), + (E["TipiTROLL"], 18), + (E["TipICRY"], 15), + (E["TipiSKULL"], 10), + (E["TipiKARIKAS"], 8), +] +_SLOTS_JACKPOT = E["TipiKARIKAS"] +_SLOTS_TRIPLE_MULT: dict[str, int] = { + E["TipiHEART"]: 4, + E["TipiFIRE"]: 5, + E["TipiTROLL"]: 7, + E["TipICRY"]: 10, + E["TipiSKULL"]: 15, + E["TipiKARIKAS"]: 25, # jackpot +} + + +def _spin() -> str: + symbols, weights = zip(*_SLOTS_SYMBOLS) + return random.choices(list(symbols), weights=list(weights), k=1)[0] + + +@_locked_by(0) +async def do_slots(user_id: int, bet: int) -> dict: + 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"} + 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"], + } + + +# --------------------------------------------------------------------------- +# /blackjack +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_blackjack_bet(user_id: int, bet: int) -> dict: + """Deduct the initial blackjack bet. Returns ok/fail.""" + 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"} + 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"]} + + +@_locked_by(0) +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"]} diff --git a/core/economy/heist.py b/core/economy/heist.py new file mode 100644 index 0000000..0bf498e --- /dev/null +++ b/core/economy/heist.py @@ -0,0 +1,97 @@ +"""Bank heist: group robbery of the house.""" + +from __future__ import annotations + +import random + +from .. import pb_client +from ..pb_client import DatabaseError +from . import house +from .store import HEIST_JAIL, _commit, _now, _txn, _user_lock, get_user +from .house import _credit_house, _refund_house_safe, _refund_user_safe + + +# --------------------------------------------------------------------------- +# /heist +# --------------------------------------------------------------------------- + +async def do_heist_check(user_id: int) -> dict: + """Check whether a user is eligible to join a heist.""" + 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"} + 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. + + Per-user commit failures attempt to compensate the house so the economy + stays balanced. If compensation also fails, a CRITICAL log is emitted. + """ + now = _now() + payout_each = 0 + failed_users: list[int] = [] + + if success and house.HOUSE_ID is not None: + try: + house = await get_user(house.HOUSE_ID) + pct = random.uniform(0.20, 0.55) + total = max(300, int(house["balance"] * pct)) + payout_each = total // len(user_ids) + # Atomic decrement (capped at the balance we read) instead of a full + # record commit, so concurrent _credit_house increments aren't lost. + debit = min(total, house["balance"]) + if debit > 0: + await pb_client.update_record(house["_pb_id"], {"balance-": debit}) # type: ignore[typeddict-item] + except DatabaseError: + return {"ok": False, "reason": "db_error"} + _txn("HEIST_HOUSE", change=f"-{debit}", house_bal=house["balance"] - debit) + + for uid in user_ids: + async with _user_lock(uid): + try: + user = await get_user(uid) + except DatabaseError: + failed_users.append(uid) + if success and payout_each > 0: + await _refund_house_safe(payout_each, "heist_win_compensate", uid) + continue + user["last_heist"] = now.isoformat() + user["heists_joined"] = user.get("heists_joined", 0) + 1 + fine_credited = False + 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: + try: + await _credit_house(fine) + fine_credited = True + except DatabaseError: + pass # user commit will still be attempted; if both fail, no economy effect + try: + await _commit(uid, user) + except DatabaseError: + failed_users.append(uid) + if success and payout_each > 0: + await _refund_house_safe(payout_each, "heist_win_compensate", uid) + elif not success and fine_credited: + await _refund_user_safe(house.HOUSE_ID, fine if 'fine' in locals() else 0, "heist_fail_compensate", uid) + + return {"ok": True, "payout_each": payout_each, "success": success, "failed_users": failed_users} diff --git a/core/economy/house.py b/core/economy/house.py new file mode 100644 index 0000000..4ee14ba --- /dev/null +++ b/core/economy/house.py @@ -0,0 +1,91 @@ +"""House account: the bot's own balance, fed by fines and lost bets.""" + +from __future__ import annotations + +from .. import pb_client +from ..pb_client import DatabaseError +from .store import _log, _now, get_user, _commit + + +# --------------------------------------------------------------------------- +# House account (bot user) +# --------------------------------------------------------------------------- +HOUSE_ID: int | None = None +_house_pb_id: str | None = None + + +def set_house(user_id: int) -> None: + """Register the bot's Discord user ID as the house account.""" + global HOUSE_ID, _house_pb_id + if HOUSE_ID != user_id: + _house_pb_id = None + HOUSE_ID = user_id + + +async def _house_record_id() -> str | None: + """PocketBase record id of the house account (cached; creates the record on first use).""" + global _house_pb_id + if HOUSE_ID is None: + return None + if _house_pb_id is None: + house = await get_user(HOUSE_ID) + _house_pb_id = house.get("_pb_id") # type: ignore[typeddict-item] + return _house_pb_id + + +async def _credit_house(amount: int) -> None: + """Add `amount` coins to the house via an atomic PocketBase increment. + + Deliberately lock-free: callers hold per-user locks, so this must never + acquire one itself (see the locking rules above).""" + if amount <= 0: + return + record_id = await _house_record_id() + if record_id is None: + return + await pb_client.update_record(record_id, {"balance+": amount}) + + +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.""" + record_id = await _house_record_id() + if record_id is None: + return + await pb_client.update_record(record_id, {"heist_global_cd_until": until}) + + +async def _refund_house_safe(amount: int, context: str, related_uid: int) -> None: + """Best-effort refund of `amount` to the house. Logs critical if it fails.""" + if HOUSE_ID is None or amount <= 0: + return + try: + await _credit_house(amount) + except DatabaseError as exc: + _log.critical( + "House compensation failed (%s, related uid %s, amount %s): %s", + context, related_uid, amount, exc, + ) + + +async def _refund_user_safe(_unused_house_id, amount: int, context: str, uid: int) -> None: + """Best-effort atomic debit of `amount` from the house (compensates a failed + user fine). Logs critical if it fails.""" + if HOUSE_ID is None or amount <= 0: + return + try: + record_id = await _house_record_id() + if record_id: + await pb_client.update_record(record_id, {"balance-": amount}) + except DatabaseError as exc: + _log.critical( + "House debit compensation failed (%s, related uid %s, amount %s): %s", + context, uid, amount, exc, + ) diff --git a/core/economy/income.py b/core/economy/income.py new file mode 100644 index 0000000..b0b1e43 --- /dev/null +++ b/core/economy/income.py @@ -0,0 +1,403 @@ +"""Income and social commands: daily, work, beg, crime, rob, give.""" + +from __future__ import annotations + +import random +from datetime import date, timedelta + +import strings + +from ..pb_client import DatabaseError +from . import house +from .store import ( + COOLDOWNS, JAIL_DURATION, PRESTIGE_SHOP, _commit, _cooldown_remaining, + _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, +) +from .house import _credit_house + + +# --------------------------------------------------------------------------- +# /daily +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_daily(user_id: int) -> dict: + 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"} + + 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 + + daily_plus_level = (user.get("prestige_upgrades") or {}).get("daily_plus", 0) + base = int(150 * (1.0 + daily_plus_level * PRESTIGE_SHOP["daily_plus"]["effect"])) + earned = int(base * streak_mult * vip_mult) + if "korvaklapid" in user["items"]: + earned += 25 + coin_mult, _ = _prestige_mult(user) + earned = int(earned * coin_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 + + +@_locked_by(0) +async def do_work(user_id: int) -> dict: + 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"} + + 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 + + work_plus_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0) + work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"] + coin_mult, _ = _prestige_mult(user) + earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult) + 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 + + +@_locked_by(0) +async def do_beg(user_id: int) -> dict: + 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"} + + 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 + coin_mult, _ = _prestige_mult(user) + earned = int(random.randint(10, 40) * beg_mult * coin_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 + + +@_locked_by(0) +async def do_crime(user_id: int) -> dict: + 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"} + + 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"], + } + + +# --------------------------------------------------------------------------- +# /rob +# --------------------------------------------------------------------------- +@_locked_by(0, 1) +async def do_rob(robber_id: int, target_id: int) -> dict: + try: + robber = await get_user(robber_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + if robber.get("eco_banned"): + return {"ok": False, "reason": "banned"} + try: + target = await get_user(target_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + + 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.HOUSE_ID is not None and target_id == house.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 + prev_lifetime_earned = robber.get("lifetime_earned", 0) + prev_biggest_win = robber.get("biggest_win", 0) + prev_peak_balance = robber.get("peak_balance", 0) + robber["balance"] += stolen + robber["lifetime_earned"] = prev_lifetime_earned + stolen + robber["biggest_win"] = max(prev_biggest_win, stolen) + robber["peak_balance"] = max(prev_peak_balance, robber["balance"]) + try: + await _commit(robber_id, robber) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + try: + await _commit(target_id, target) + except DatabaseError: + robber["balance"] -= stolen + robber["lifetime_earned"] = prev_lifetime_earned + robber["biggest_win"] = prev_biggest_win + robber["peak_balance"] = prev_peak_balance + try: + await _commit(robber_id, robber) + except DatabaseError as exc2: + _log.critical( + "do_rob rollback failed for robber %s after target commit failed: %s", + robber_id, exc2, + ) + return {"ok": False, "reason": "db_error"} + _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"]} + + +# --------------------------------------------------------------------------- +# /give +# --------------------------------------------------------------------------- +@_locked_by(0, 1) +async def do_give(giver_id: int, receiver_id: int, amount: int) -> dict: + try: + giver = await get_user(giver_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + 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"} + + try: + receiver = await get_user(receiver_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + giver["balance"] -= amount + receiver["balance"] += amount + giver["total_given"] = giver.get("total_given", 0) + amount + receiver["total_received"] = receiver.get("total_received", 0) + amount + try: + await _commit(giver_id, giver) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + try: + await _commit(receiver_id, receiver) + except DatabaseError: + giver["balance"] += amount + giver["total_given"] = max(0, giver.get("total_given", 0) - amount) + try: + await _commit(giver_id, giver) + except DatabaseError as exc2: + _log.critical( + "do_give rollback failed for giver %s after receiver commit failed: %s", + giver_id, exc2, + ) + return {"ok": False, "reason": "db_error"} + _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"], + } diff --git a/core/economy/jail.py b/core/economy/jail.py new file mode 100644 index 0000000..84ce0c0 --- /dev/null +++ b/core/economy/jail.py @@ -0,0 +1,79 @@ +"""Jail, jailbreak and bail.""" + +from __future__ import annotations + +import random +from datetime import timedelta + +from .store import ( + _commit, _is_jailed, _locked_by, _now, _txn, get_all_users_raw, get_user, +) + + +@_locked_by(0) +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"]) + + +# --------------------------------------------------------------------------- +# /jailbreak (Monopoly-style dice rolls) +# --------------------------------------------------------------------------- +@_locked_by(0) +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) + + +@_locked_by(0) +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 + +@_locked_by(0) +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"]} + + +# --------------------------------------------------------------------------- +# /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 diff --git a/core/economy/leaderboards.py b/core/economy/leaderboards.py new file mode 100644 index 0000000..7b963fa --- /dev/null +++ b/core/economy/leaderboards.py @@ -0,0 +1,82 @@ +"""Leaderboard queries over the full collection.""" + +from __future__ import annotations + +from .. import pb_client +from .levels import get_level + + +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] + + +# --------------------------------------------------------------------------- +# Extended leaderboards +# --------------------------------------------------------------------------- +async def get_leaderboard_season_exp(top_n: int | None = 10) -> list[tuple[str, int, int]]: + """Return (user_id, season_total_exp, prestige_level) sorted by season EXP.""" + records = await pb_client.list_all_records() + result = sorted( + ( + (r["user_id"], r.get("season_total_exp", 0), r.get("prestige_level", 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_prestige(top_n: int | None = 10) -> list[tuple[str, int, int]]: + """Return (user_id, prestige_level, prestige_points) sorted by prestige_level then PP.""" + records = await pb_client.list_all_records() + result = sorted( + ( + (r["user_id"], r.get("prestige_level", 0), r.get("prestige_points", 0)) + for r in records if r.get("user_id") + ), + key=lambda x: (x[1], x[2]), + reverse=True, + ) + return result if top_n is None else result[:top_n] + + +async def get_leaderboard_wagered(top_n: int | None = 10) -> list[tuple[str, int]]: + """Return (user_id, total_wagered) sorted descending.""" + records = await pb_client.list_all_records() + result = sorted( + ((r["user_id"], r.get("total_wagered", 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_fish(top_n: int | None = 10) -> list[tuple[str, int]]: + """Return (user_id, total_fish_caught) sorted descending.""" + records = await pb_client.list_all_records() + result = sorted( + ((r["user_id"], r.get("total_fish_caught", 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] diff --git a/core/economy/levels.py b/core/economy/levels.py new file mode 100644 index 0000000..0067379 --- /dev/null +++ b/core/economy/levels.py @@ -0,0 +1,80 @@ +"""EXP, levels and vanity role thresholds.""" + +from __future__ import annotations + +import math + +from .store import _locked_by, _prestige_mult, get_user, _commit + + +# --------------------------------------------------------------------------- +# 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. + Recurrence: exp_for_level(L) = L*20 - 10 + exp_for_level(L-1), base 0. + Closed form: 10*level^2.""" + if level <= 1: + return 0 + return 10 * level * level + + +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] + + +@_locked_by(0) +async def award_exp(user_id: int, amount: int) -> dict: + """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp.""" + user = await get_user(user_id) + _, exp_mult = _prestige_mult(user) + gained = max(1, int(amount * exp_mult)) + old_exp = user.get("exp", 0) + new_exp = old_exp + gained + old_level = get_level(old_exp) + new_level = get_level(new_exp) + user["exp"] = new_exp + user["season_total_exp"] = user.get("season_total_exp", 0) + gained + await _commit(user_id, user) + return {"old_level": old_level, "new_level": new_level, "exp": new_exp, "gained": gained} diff --git a/core/economy/prestige.py b/core/economy/prestige.py new file mode 100644 index 0000000..5fa252d --- /dev/null +++ b/core/economy/prestige.py @@ -0,0 +1,106 @@ +"""Prestige resets and the prestige upgrade shop.""" + +from __future__ import annotations + +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E +from .store import PRESTIGE_SHOP, _commit, _locked_by, _txn, get_user +from .levels import get_level + + +PRESTIGE_ROLE = "TipiPRESTIGE" +PRESTIGE_MIN_LEVEL = 30 # minimum level required to prestige + + +# --------------------------------------------------------------------------- +# /prestige +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_prestige(user_id: int) -> dict: + """Prestige: requires level 30, earns PP, resets balance/exp/items/cooldowns.""" + 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"} + + exp = user.get("exp", 0) + level = get_level(exp) + if level < PRESTIGE_MIN_LEVEL: + return {"ok": False, "reason": "level_too_low", "level": level, "required": PRESTIGE_MIN_LEVEL} + + pp_earned = max(1, exp // 1000) + new_prestige_level = user.get("prestige_level", 0) + 1 + + # Preserve: fish_book, fish_inventory, lifetime stats, prestige_points, season_total_exp, prestige_upgrades + user["balance"] = 0 + user["exp"] = 0 + user["items"] = [] + user["item_uses"] = {} + user["last_daily"] = None + user["last_work"] = None + user["last_beg"] = None + user["last_crime"] = None + user["last_rob"] = None + user["last_fish"] = None + user["last_heist"] = None + user["daily_streak"] = 0 + user["last_streak_date"] = None + user["jailed_until"] = None + user["jailbreak_used"] = False + user["prestige_level"] = new_prestige_level + user["prestige_points"] = user.get("prestige_points", 0) + pp_earned + + await _commit(user_id, user) + _txn("PRESTIGE", user=user_id, pp_earned=pp_earned, prestige=new_prestige_level, old_exp=exp) + + return { + "ok": True, + "pp_earned": pp_earned, + "prestige_level": new_prestige_level, + "prestige_points": user["prestige_points"], + "old_exp": exp, + } + + +@_locked_by(0) +async def do_prestige_buy(user_id: int, upgrade_id: str) -> dict: + """Spend PP to buy a prestige upgrade level.""" + if upgrade_id not in PRESTIGE_SHOP: + return {"ok": False, "reason": "not_found"} + + 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"} + + upgrade = PRESTIGE_SHOP[upgrade_id] + upgrades: dict = user.get("prestige_upgrades") or {} + current_level = upgrades.get(upgrade_id, 0) + + if current_level >= upgrade["max_level"]: + return {"ok": False, "reason": "maxed", "max": upgrade["max_level"]} + + pp = user.get("prestige_points", 0) + cost = upgrade["pp_cost"] + if pp < cost: + return {"ok": False, "reason": "insufficient_pp", "have": pp, "need": cost} + + upgrades[upgrade_id] = current_level + 1 + user["prestige_upgrades"] = upgrades + user["prestige_points"] = pp - cost + + await _commit(user_id, user) + _txn("PRESTIGE_BUY", user=user_id, upgrade=upgrade_id, + new_level=upgrades[upgrade_id], pp_left=user["prestige_points"]) + + return { + "ok": True, + "upgrade_id": upgrade_id, + "new_level": upgrades[upgrade_id], + "max_level": upgrade["max_level"], + "pp_remaining": user["prestige_points"], + } diff --git a/core/economy/quests.py b/core/economy/quests.py new file mode 100644 index 0000000..9c02252 --- /dev/null +++ b/core/economy/quests.py @@ -0,0 +1,159 @@ +"""Daily/weekly quests tracked from lifetime counters.""" + +from __future__ import annotations + +import random +from typing import TypedDict + +from .store import ( + UserData, _commit, _locked_by, _log, _now, _prestige_mult, get_user, +) + + +# --------------------------------------------------------------------------- +# Quest system +# --------------------------------------------------------------------------- +# Quests reuse the monotonic lifetime counters already tracked on each user. +# Progress = (current counter value) - (snapshot taken when the quest was rolled). +# Reset is lazy & per-user: the active set is regenerated the first time a user +# interacts after the day / ISO-week rolls over (mirrors the streak-date logic). +# Rotation is seeded by user id + period key, so each player gets their own set. +class QuestDef(TypedDict): + stat: str # UserData counter field the quest tracks + goal: int + coins: int + exp: int + + +QUESTS_DAILY: dict[str, QuestDef] = { + "work3": {"stat": "work_count", "goal": 3, "coins": 150, "exp": 20}, + "beg5": {"stat": "beg_count", "goal": 5, "coins": 100, "exp": 15}, + "wager500": {"stat": "total_wagered", "goal": 500, "coins": 150, "exp": 20}, + "fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20}, + "crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25}, + "earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20}, + "give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15}, +} + +QUESTS_WEEKLY: dict[str, QuestDef] = { + "work20": {"stat": "work_count", "goal": 20, "coins": 1000, "exp": 100}, + "fish15": {"stat": "total_fish_caught", "goal": 15, "coins": 1200, "exp": 100}, + "wager5000": {"stat": "total_wagered", "goal": 5000, "coins": 1000, "exp": 100}, + "crime5": {"stat": "crimes_succeeded", "goal": 5, "coins": 1200, "exp": 120}, + "heist1": {"stat": "heists_joined", "goal": 1, "coins": 800, "exp": 80}, + "earn10000": {"stat": "lifetime_earned", "goal": 10000, "coins": 1500, "exp": 150}, +} + +DAILY_QUEST_COUNT = 3 +WEEKLY_QUEST_COUNT = 2 + + +def _period_keys() -> tuple[str, str]: + """Return (day_key, week_key) for the current UTC time.""" + today = _now().date() + iso = today.isocalendar() + return today.isoformat(), f"{iso[0]}-W{iso[1]:02d}" + + +def _pick_quests(pool: dict[str, QuestDef], count: int, seed: str) -> list[str]: + """Deterministically choose `count` quest ids from `pool` for a period.""" + rng = random.Random(seed) + return rng.sample(sorted(pool.keys()), min(count, len(pool))) + + +def _new_quest_block( + user: UserData, user_id: int, pool: dict[str, QuestDef], count: int, + period_val: str, period_field: str +) -> dict: + chosen = _pick_quests(pool, count, f"{user_id}:{period_field}:{period_val}") + return { + period_field: period_val, + "quests": { + qid: {"snap": int(user.get(pool[qid]["stat"], 0) or 0), "claimed": False} + for qid in chosen + }, + } + + +def _ensure_quests(user: UserData, user_id: int) -> bool: + """Roll fresh daily/weekly quest sets if their period elapsed. + Mutates `user` in place; returns True if anything changed (caller commits).""" + changed = False + day_key, week_key = _period_keys() + if (user.get("quest_daily") or {}).get("date") != day_key: + user["quest_daily"] = _new_quest_block(user, user_id, QUESTS_DAILY, DAILY_QUEST_COUNT, day_key, "date") + changed = True + if (user.get("quest_weekly") or {}).get("week") != week_key: + user["quest_weekly"] = _new_quest_block(user, user_id, QUESTS_WEEKLY, WEEKLY_QUEST_COUNT, week_key, "week") + changed = True + return changed + + +def _quest_progress(user: UserData, pool: dict[str, QuestDef], qid: str, state: dict) -> int: + cur = int(user.get(pool[qid]["stat"], 0) or 0) + return max(0, cur - int(state.get("snap", 0))) + + +def _quest_view(user: UserData) -> dict: + def build(pool: dict[str, QuestDef], block: dict) -> list[dict]: + out: list[dict] = [] + for qid, state in (block.get("quests") or {}).items(): + if qid not in pool: + continue + d = pool[qid] + prog = min(d["goal"], _quest_progress(user, pool, qid, state)) + out.append({ + "id": qid, "goal": d["goal"], "coins": d["coins"], "exp": d["exp"], + "progress": prog, "done": prog >= d["goal"], "claimed": bool(state.get("claimed")), + }) + return out + return { + "daily": build(QUESTS_DAILY, user.get("quest_daily") or {}), + "weekly": build(QUESTS_WEEKLY, user.get("quest_weekly") or {}), + } + + +@_locked_by(0) +async def get_quests(user_id: int) -> dict: + """Return the user's active quests, rolling new sets if the period elapsed.""" + user = await get_user(user_id) + if _ensure_quests(user, user_id): + saved = await _commit(user_id, user) + if saved is not None and "quest_daily" not in saved: + _log.warning( + "PocketBase collection has no quest fields - quest state is not " + "persisted and progress will stay at 0. Run scripts/add_quest_fields.py." + ) + return _quest_view(user) + + +@_locked_by(0) +async def claim_quests(user_id: int) -> dict: + """Grant coins for every completed-but-unclaimed quest and mark them claimed. + Coins (with prestige coin_mult) are paid here; EXP is returned raw for the + caller to award via the shared award_exp path (keeps level-up notices).""" + user = await get_user(user_id) + _ensure_quests(user, user_id) + coin_mult, _ = _prestige_mult(user) + total_coins = total_exp = claimed = 0 + for pool, block in ( + (QUESTS_DAILY, user.get("quest_daily") or {}), + (QUESTS_WEEKLY, user.get("quest_weekly") or {}), + ): + for qid, state in (block.get("quests") or {}).items(): + if qid not in pool or state.get("claimed"): + continue + if _quest_progress(user, pool, qid, state) < pool[qid]["goal"]: + continue + total_coins += pool[qid]["coins"] + total_exp += pool[qid]["exp"] + state["claimed"] = True + claimed += 1 + if not claimed: + return {"ok": False, "reason": "nothing"} + coins_awarded = int(total_coins * coin_mult) + user["balance"] += coins_awarded + user["lifetime_earned"] = user.get("lifetime_earned", 0) + coins_awarded + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + await _commit(user_id, user) + return {"ok": True, "claimed": claimed, "coins": coins_awarded, "exp": total_exp, "balance": user["balance"]} diff --git a/core/economy/shop.py b/core/economy/shop.py new file mode 100644 index 0000000..e2954b5 --- /dev/null +++ b/core/economy/shop.py @@ -0,0 +1,208 @@ +"""Shop catalogue and purchases.""" + +from __future__ import annotations + +from typing import TypedDict + +import strings + +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E +from .store import _locked_by, _txn, get_user, _commit +from .levels import get_level + + +# --------------------------------------------------------------------------- +# Shop catalogue +# --------------------------------------------------------------------------- +class ShopItem(TypedDict): + name: str + emoji: str + cost: int + description: str + + +SHOP: dict[str, ShopItem] = { + "gaming_hiir": { + "name": "Mängurihiir", + "emoji": E["TipiHIIR"], + "cost": 500, + "description": strings.ITEM_DESCRIPTIONS["gaming_hiir"], + }, + "hiirematt": { + "name": "Hiirematt", + "emoji": E["TipiMATT"], + "cost": 600, + "description": strings.ITEM_DESCRIPTIONS["hiirematt"], + }, + "korvaklapid": { + "name": "K\u00f5rvaklapid", + "emoji": E["TipiKLAPID"], + "cost": 1200, + "description": strings.ITEM_DESCRIPTIONS["korvaklapid"], + }, + "lan_pass": { + "name": "LAN pilet", + "emoji": E["TipiPILET"], + "cost": 1200, + "description": strings.ITEM_DESCRIPTIONS["lan_pass"], + }, + "energiajook": { + "name": "Red Bull", + "emoji": E["TipiBULL"], + "cost": 800, + "description": strings.ITEM_DESCRIPTIONS["energiajook"], + }, + "gaming_laptop": { + "name": "Bot Farm", + "emoji": E["TipiLAP"], + "cost": 1500, + "description": strings.ITEM_DESCRIPTIONS["gaming_laptop"], + }, + "anticheat": { + "name": "Anticheat", + "emoji": E["TipiVAC"], + "cost": 1000, + "description": strings.ITEM_DESCRIPTIONS["anticheat"], + }, + # ----- Tier 2 ----- + "reguleeritav_laud": { + "name": "Reguleeritav laud", + "emoji": E["TipiLAUD"], + "cost": 3500, + "description": strings.ITEM_DESCRIPTIONS["reguleeritav_laud"], + }, + "jellyfin": { + "name": "Jellyfin server", + "emoji": E["TipiSERVER"], + "cost": 4000, + "description": strings.ITEM_DESCRIPTIONS["jellyfin"], + }, + "mikrofon": { + "name": "Eraldiseisev mikrofon", + "emoji": E["TipiMIC"], + "cost": 2800, + "description": strings.ITEM_DESCRIPTIONS["mikrofon"], + }, + "klaviatuur": { + "name": "Mehaaniline klaviatuur", + "emoji": E["TipiKLAVA"], + "cost": 1800, + "description": strings.ITEM_DESCRIPTIONS["klaviatuur"], + }, + "monitor": { + "name": "Ultralai monitor", + "emoji": E["TipiMONITOR"], + "cost": 2500, + "description": strings.ITEM_DESCRIPTIONS["monitor"], + }, + "cat6": { + "name": "Cat6 kaabel", + "emoji": E["TipiCAT"], + "cost": 3500, + "description": strings.ITEM_DESCRIPTIONS["cat6"], + }, + # ----- Tier 3 ----- + "monitor_360": { + "name": "360Hz monitor", + "emoji": E["TipiMONITOR2"], + "cost": 7500, + "description": strings.ITEM_DESCRIPTIONS["monitor_360"], + }, + "karikas": { + "name": "TipiLAN karikas", + "emoji": E["TipiKARIKAS"], + "cost": 6000, + "description": strings.ITEM_DESCRIPTIONS["karikas"], + }, + "gaming_tool": { + "name": "Gaming tool", + "emoji": E["TipiTOOL"], + "cost": 9000, + "description": strings.ITEM_DESCRIPTIONS["gaming_tool"], + }, + # ----- Fishing items ----- + "ussipurk": { + "name": "Ussipurk", + "emoji": "🪣", + "cost": 3500, + "description": strings.ITEM_DESCRIPTIONS["ussipurk"], + }, + "kalavork": { + "name": "Kalavõrk", + "emoji": "🪝", + "cost": 5000, + "description": strings.ITEM_DESCRIPTIONS["kalavork"], + }, + "echolood": { + "name": "Echolood", + "emoji": "📡", + "cost": 8000, + "description": strings.ITEM_DESCRIPTIONS["echolood"], + }, +} + +# 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", "ussipurk"], + 3: ["monitor_360", "karikas", "gaming_tool", "kalavork", "echolood"], +} + +# 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, + "ussipurk": 10, + "monitor_360": 20, + "karikas": 20, + "gaming_tool": 20, + "kalavork": 20, + "echolood": 20, +} + + +# --------------------------------------------------------------------------- +# /buy +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_buy(user_id: int, item_id: str) -> dict: + if item_id not in SHOP: + return {"ok": False, "reason": "not_found"} + 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"} + + 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"]} diff --git a/core/economy/store.py b/core/economy/store.py new file mode 100644 index 0000000..70a1ec4 --- /dev/null +++ b/core/economy/store.py @@ -0,0 +1,384 @@ +"""Shared foundation: user records, locks, time, cooldowns, txn log.""" + +from __future__ import annotations + +import asyncio +import functools +import logging +from datetime import datetime, timedelta, timezone +from typing import TypedDict + +import aiohttp + +from .. import pb_client +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E + + +def _clock() -> datetime: + """Actual time source - a seam so tests can freeze time everywhere at once.""" + return datetime.now(tz=timezone.utc) + + +def _now() -> datetime: + return _clock() + + +_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) + + +# Per-profile emoji values live in core/emoji.py; add new IDs there. +COIN = E["TipiCOIN"] +PP_EMOJI = E["TipiFIRE"] + +# --------------------------------------------------------------------------- +# Prestige shop catalogue +# --------------------------------------------------------------------------- +class PrestigeItem(TypedDict): + emoji: str + max_level: int + pp_cost: int + effect: float + + +PRESTIGE_SHOP: dict[str, PrestigeItem] = { + "coin_mult": { + "emoji": E["TipiCOIN"], + "max_level": 5, + "pp_cost": 5, + "effect": 0.08, + }, + "exp_mult": { + "emoji": "✨", + "max_level": 5, + "pp_cost": 5, + "effect": 0.08, + }, + "daily_plus": { + "emoji": "📅", + "max_level": 3, + "pp_cost": 7, + "effect": 0.20, + }, + "work_plus": { + "emoji": "💼", + "max_level": 3, + "pp_cost": 7, + "effect": 0.20, + }, +} + + +# --------------------------------------------------------------------------- +# 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), + "fish": timedelta(minutes=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 + # Prestige system + prestige_level: int + prestige_points: int + season_total_exp: int # cumulative EXP this season (survives prestige resets) + prestige_upgrades: dict # {upgrade_id: level} + # Fishing system + last_fish: str | None + fish_book: dict # {fish_id: times_caught} + total_fish_caught: int + fish_inventory: list # [{fish_id, weight, value}] - survives prestige + # Quest system + quest_daily: dict # {"date": "YYYY-MM-DD", "quests": {qid: {snap, claimed}}} + quest_weekly: dict # {"week": "YYYY-Www", "quests": {qid: {snap, claimed}}} + + +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, + # ── Prestige ───────────────────────────────────────────────────────── + "prestige_level": 0, + "prestige_points": 0, + "season_total_exp": 0, + "prestige_upgrades": {}, + # ── Fishing ────────────────────────────────────────────────────────── + "last_fish": None, + "fish_book": {}, + "total_fish_caught": 0, + "fish_inventory": [], + # ── Quests ─────────────────────────────────────────────────────────── + "quest_daily": {}, + "quest_weekly": {}, + } + + +# --------------------------------------------------------------------------- +# Persistence (PocketBase backend) +# --------------------------------------------------------------------------- +_log = logging.getLogger("tipiCOIN.economy") + +# --------------------------------------------------------------------------- +# Per-user write locks +# --------------------------------------------------------------------------- +# Every mutation is a read-modify-write cycle (get_user → mutate → _commit); +# without serialization, two concurrent commands for the same user overwrite +# each other's commit. Locking rules that keep this deadlock-free: +# - a decorated function must never call another decorated function +# - house balance changes go through _credit_house (an atomic PocketBase +# increment, no lock), so they are safe while holding user locks +_user_locks: dict[int, asyncio.Lock] = {} + + +def _user_lock(user_id: int) -> asyncio.Lock: + lock = _user_locks.get(user_id) + if lock is None: + lock = _user_locks[user_id] = asyncio.Lock() + return lock + + +def _locked_by(*arg_positions: int): + """Serialize the decorated function per user id found at the given + positional-argument indices. Multiple ids are acquired in sorted order so + two-user functions (do_give, do_rob) cannot deadlock each other.""" + def decorator(fn): + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + locks = [_user_lock(uid) for uid in sorted({args[pos] for pos in arg_positions})] + for lock in locks: + await lock.acquire() + try: + return await fn(*args, **kwargs) + finally: + for lock in reversed(locks): + lock.release() + return wrapper + return decorator + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- +async def missing_schema_fields() -> list[str]: + """Compare the live PocketBase collection schema against every field the + bot persists. PocketBase silently drops writes to undeclared fields, so + any name returned here means broken features without error messages.""" + live = await pb_client.get_collection_fields() + expected = set(_default_user()) | {"user_id"} + return sorted(expected - live) + + +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 + + +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) + try: + 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) + except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: + _log.error("PocketBase unreachable for user %s: %s", user_id, exc) + raise DatabaseError(f"Database unavailable: {exc}") from exc + 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 + + +def _prestige_mult(user: UserData) -> tuple[float, float]: + """Return (coin_mult, exp_mult) based on prestige upgrades. Both ≥1.0.""" + upgrades: dict = user.get("prestige_upgrades") or {} # type: ignore[assignment] + coin_level = upgrades.get("coin_mult", 0) + exp_level = upgrades.get("exp_mult", 0) + return ( + 1.0 + coin_level * PRESTIGE_SHOP["coin_mult"]["effect"], + 1.0 + exp_level * PRESTIGE_SHOP["exp_mult"]["effect"], + ) + + +# --------------------------------------------------------------------------- +# Internal write helper +# --------------------------------------------------------------------------- +async def _commit(user_id: int, user: UserData) -> dict | None: + """Persist the full user record. Returns the record as PocketBase stored it + (fields absent from the collection schema are silently dropped by PB).""" + 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) + try: + if record_id: + return 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] + return created + except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: + _log.error("_commit failed for user %s: %s", user_id, exc) + raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc + + +# --------------------------------------------------------------------------- +# /reminders +# --------------------------------------------------------------------------- +@_locked_by(0) +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) diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index cc2e400..64aed7f 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -7,6 +7,7 @@ Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`). - Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; every player gets their own personal set that rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP. - Fixed concurrent commands being able to overwrite each other's balance changes — every economy action now runs under a per-user lock, and house payouts use atomic database increments +- Fixed the maintenance pause and channel restrictions never actually being enforced — the global command check was registered incorrectly and silently did nothing (command logging was also affected) ## v0.1.0 — 2026-05-03 diff --git a/tests/conftest.py b/tests/conftest.py index 1a7636a..ee36065 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,8 +90,9 @@ def _install(monkeypatch, fake: FakePocketBase) -> FakePocketBase: for name in ("get_record", "create_record", "update_record", "list_all_records", "count_records", "get_collection_fields"): monkeypatch.setattr(pb_client, name, getattr(fake, name)) - monkeypatch.setattr(economy, "HOUSE_ID", None) - monkeypatch.setattr(economy, "_house_pb_id", None) + # live house state is owned by economy.house (the package re-export is a snapshot) + monkeypatch.setattr(economy.house, "HOUSE_ID", None) + monkeypatch.setattr(economy.house, "_house_pb_id", None) economy._user_locks.clear() return fake diff --git a/tests/test_economy_flows.py b/tests/test_economy_flows.py index 44684e5..f768df4 100644 --- a/tests/test_economy_flows.py +++ b/tests/test_economy_flows.py @@ -14,7 +14,8 @@ HOUSE = 999 def _fixed_now(monkeypatch, dt: datetime): - monkeypatch.setattr(economy, "_now", lambda: dt) + # _clock is the time seam shared by every economy submodule + monkeypatch.setattr(economy.store, "_clock", lambda: dt) return dt diff --git a/tests/test_quests.py b/tests/test_quests.py index a261e92..cf90a61 100644 --- a/tests/test_quests.py +++ b/tests/test_quests.py @@ -11,7 +11,7 @@ UID = 111 def _fixed_now(monkeypatch, dt: datetime): - monkeypatch.setattr(economy, "_now", lambda: dt) + monkeypatch.setattr(economy.store, "_clock", lambda: dt) return dt