From a2e1601d0e056bd3d14b91dc752ecc6c55e469f7 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Wed, 19 Aug 2026 20:32:36 +0300 Subject: [PATCH] feat(economy): add /consumables shop of repeatable, expiring boosts The economy had many coin faucets but almost no sinks: the /shop is one-time ownership, and gambling/rob fines route to the house (which players drain back via jackpots and heists), so they recirculate rather than destroy coins. Result: steady inflation. Add a consumables shop as a true recurring sink - buying destroys the coins and grants a temporary boost, so there's always something to spend on after gear is maxed: - Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime - XP jook (500) - 1h of 2x EXP - Kohv (300) - instantly clears all cooldowns Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned on read; rebuying extends the timer. Effects hook where they belong: earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in levels.award_exp; kohv is self-contained. New /consumables command browses the menu (with active buffs) or buys a boost. Covered by 9 tests. Note: active_buffs is a new PocketBase field - run scripts/sync_pb_schema.py before deploying or buffs won't persist. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/economy_support_commands.py | 75 ++++++++++++++ core/economy/__init__.py | 5 +- core/economy/consumables.py | 146 +++++++++++++++++++++++++++ core/economy/income.py | 6 +- core/economy/levels.py | 3 +- core/economy/store.py | 2 + docs/PATCHNOTES.md | 5 + strings/__init__.py | 4 + strings/commands.py | 2 + strings/economy.py | 26 +++++ tests/test_consumables.py | 102 +++++++++++++++++++ 11 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 core/economy/consumables.py create mode 100644 tests/test_consumables.py diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 5d11e56..f003096 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -163,6 +163,81 @@ def register_economy_support_commands( await interaction.response.send_message(embed=embed, view=view) view.message = await interaction.original_response() + # -- /consumables ------------------------------------------------------- + def _consumables_embed(user_data: dict) -> discord.Embed: + embed = discord.Embed( + title=S.CONSUMABLES_UI["title"], + description=S.CONSUMABLES_UI["desc"].format(bal=coin(user_data["balance"])), + color=0xF4C430, + ) + active = economy.active_buffs(user_data) + if active: + lines = [ + S.CONSUMABLES_UI["buff_line"].format( + name=S.CONSUMABLES_UI[f"kind_{kind}"], + time=economy.format_td(economy.buff_remaining(user_data, kind)), + ) + for kind in active + ] + buff_value = "\n".join(lines) + else: + buff_value = S.CONSUMABLES_UI["active_none"] + embed.add_field(name=S.CONSUMABLES_UI["active_header"], value=buff_value, inline=False) + for cons in economy.CONSUMABLES.values(): + embed.add_field( + name=f"{cons['emoji']} {cons['name']} · {cons['cost']} {economy.COIN}", + value=cons["description"], + inline=False, + ) + return embed + + @tree.command(name="consumables", description=S.CMD["consumables"]) + @app_commands.describe(ese=S.OPT["consumable_ese"]) + @app_commands.choices( + ese=[ + app_commands.Choice(name=f"{c['name']} ({c['cost']} TipiCOINi)", value=cid) + for cid, c in economy.CONSUMABLES.items() + ] + ) + async def cmd_consumables( + interaction: discord.Interaction, + ese: app_commands.Choice[str] | None = None, + ): + if ese is None: + data = await economy.get_user(interaction.user.id) + await interaction.response.send_message( + embed=_consumables_embed(data), ephemeral=True + ) + return + + res = await economy.do_buy_consumable(interaction.user.id, ese.value) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "insufficient": + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + else: + await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) + return + + cons = res["consumable"] + if res["instant"]: + desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"])) + else: + key = "bought_extended" if res["extended"] else "bought_buff" + desc = S.CONSUMABLES_UI[key].format( + time=economy.format_td(res["remaining"]), + balance=coin(res["balance"]), + ) + embed = discord.Embed( + title=S.CONSUMABLES_UI["bought_title"].format(emoji=cons["emoji"], name=cons["name"]), + description=desc, + color=0x57F287, + ) + await interaction.response.send_message(embed=embed) + class RemindersSelect(discord.ui.Select): def __init__(self, user_id: int, current: list[str]): self.user_id = user_id diff --git a/core/economy/__init__.py b/core/economy/__init__.py index eb47512..52bb322 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -18,6 +18,7 @@ from .house import * from .house import _credit_house, _house_record_id from .levels import * from .shop import * +from .consumables import * from .fishing import * from .quests import * from .quests import _ensure_quests, _pick_quests, _quest_view @@ -30,6 +31,6 @@ 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, + admin, consumables, fishing, gambling, heist, house, income, jail, + leaderboards, levels, prestige, quests, shop, store, ) diff --git a/core/economy/consumables.py b/core/economy/consumables.py new file mode 100644 index 0000000..09f5aae --- /dev/null +++ b/core/economy/consumables.py @@ -0,0 +1,146 @@ +"""Consumables: repeatable, expiring boosts - a recurring coin sink. + +Unlike SHOP items (permanent, bought once), consumables are bought over and +over. Buying either activates a timed buff (stored in user["active_buffs"] as +{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed - +so this keeps the shop relevant, and the economy draining, after a player has +maxed out permanent gear. + +Effect hooks live where the effect belongs: + - "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult) + - "exp" buff -> levels.award_exp (via exp_buff_mult) + - instant kohv -> handled entirely here (wipes cooldown timestamps) +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TypedDict + +import strings + +from ..pb_client import DatabaseError +from ..emoji import EMOJI as E +from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user + + +class Consumable(TypedDict): + name: str + emoji: str + cost: int + description: str + kind: str # active_buffs key ("earn"/"exp"), or "instant" + duration_min: int # buff lifetime in minutes (0 for instant effects) + + +CONSUMABLES: dict[str, Consumable] = { + "energy_xl": { + "name": "Energiajook XL", + "emoji": E["TipiBULL"], + "cost": 500, + "description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"], + "kind": "earn", + "duration_min": 60, + }, + "xp_potion": { + "name": "XP jook", + "emoji": "✨", + "cost": 500, + "description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"], + "kind": "exp", + "duration_min": 60, + }, + "kohv": { + "name": "Kohv", + "emoji": "☕", + "cost": 300, + "description": strings.CONSUMABLE_DESCRIPTIONS["kohv"], + "kind": "instant", + "duration_min": 0, + }, +} + +# Multiplier granted while a buff of each kind is active. +_BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0} + +# Cooldown timestamps an instant "kohv" clears. +_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish") + + +# --------------------------------------------------------------------------- +# Buff inspection helpers (pure - safe to call while holding a user lock) +# --------------------------------------------------------------------------- +def active_buffs(user) -> dict[str, str]: + """Return {kind: expiry_iso} for buffs that have not expired yet.""" + buffs = user.get("active_buffs") or {} + now = _now() + out: dict[str, str] = {} + for kind, expiry in buffs.items(): + dt = _parse_dt(expiry) + if dt is not None and dt > now: + out[kind] = expiry + return out + + +def buff_remaining(user, kind: str) -> timedelta | None: + """Remaining time on a buff kind, or None if it is inactive.""" + expiry = active_buffs(user).get(kind) + if expiry is None: + return None + return _parse_dt(expiry) - _now() + + +def earn_mult(user) -> float: + """Earnings multiplier from an active 'earn' buff (1.0 if none).""" + return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0 + + +def exp_buff_mult(user) -> float: + """EXP multiplier from an active 'exp' buff (1.0 if none).""" + return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0 + + +# --------------------------------------------------------------------------- +# /consumables purchase +# --------------------------------------------------------------------------- +@_locked_by(0) +async def do_buy_consumable(user_id: int, cons_id: str) -> dict: + if cons_id not in CONSUMABLES: + 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"} + + cons = CONSUMABLES[cons_id] + if user["balance"] < cons["cost"]: + return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]} + + user["balance"] -= cons["cost"] + + extended = False + if cons["kind"] == "instant": + for field in _COOLDOWN_FIELDS: + user[field] = None + else: + buffs = dict(user.get("active_buffs") or {}) + current = _parse_dt(buffs.get(cons["kind"])) + # Stack: extend from the current expiry if still active, else from now. + extended = current is not None and current > _now() + start = current if extended else _now() + buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat() + user["active_buffs"] = buffs + + await _commit(user_id, user) + _txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"]) + + return { + "ok": True, + "consumable": cons, + "balance": user["balance"], + "instant": cons["kind"] == "instant", + "extended": extended, + "remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]), + } diff --git a/core/economy/income.py b/core/economy/income.py index b0b1e43..8c5f265 100644 --- a/core/economy/income.py +++ b/core/economy/income.py @@ -14,6 +14,7 @@ from .store import ( _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, ) from .house import _credit_house +from .consumables import earn_mult # --------------------------------------------------------------------------- @@ -126,7 +127,7 @@ async def do_work(user_id: int) -> dict: 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) + earned = int(base * job_mult * worker_mult * desk_mult * (3.0 if lucky else 1.0) * work_plus_mult * coin_mult * earn_mult(user)) user["balance"] += earned user["last_work"] = _now().isoformat() user["work_count"] = user.get("work_count", 0) + 1 @@ -169,7 +170,7 @@ async def do_beg(user_id: int) -> dict: 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) + earned = int(random.randint(10, 40) * beg_mult * coin_mult * earn_mult(user)) user["balance"] += earned user["last_beg"] = _now().isoformat() user["beg_count"] = user.get("beg_count", 0) + 1 @@ -217,6 +218,7 @@ async def do_crime(user_id: int) -> dict: earned = random.randint(200, 500) if "mikrofon" in user["items"]: earned = int(earned * 1.3) + earned = int(earned * earn_mult(user)) user["balance"] += earned user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1 user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned diff --git a/core/economy/levels.py b/core/economy/levels.py index 0067379..5a965d0 100644 --- a/core/economy/levels.py +++ b/core/economy/levels.py @@ -5,6 +5,7 @@ from __future__ import annotations import math from .store import _locked_by, _prestige_mult, get_user, _commit +from .consumables import exp_buff_mult # --------------------------------------------------------------------------- @@ -69,7 +70,7 @@ 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)) + gained = max(1, int(amount * exp_mult * exp_buff_mult(user))) old_exp = user.get("exp", 0) new_exp = old_exp + gained old_level = get_level(old_exp) diff --git a/core/economy/store.py b/core/economy/store.py index 70a1ec4..5f4d43b 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -106,6 +106,7 @@ class UserData(TypedDict, total=False): last_streak_date: str | None # ISO date "YYYY-MM-DD" items: list[str] item_uses: dict # {item_id: remaining_uses} for consumables + active_buffs: dict # {buff_kind: expiry_iso} for timed consumable boosts jailed_until: str | None # ISO datetime or None jailbreak_used: bool reminders: list[str] # command names user wants DM reminders for @@ -159,6 +160,7 @@ def _default_user() -> UserData: "last_streak_date": None, "items": [], "item_uses": {}, + "active_buffs": {}, "jailed_until": None, "jailbreak_used": False, "reminders": ["daily", "work", "beg", "crime", "rob"], diff --git a/docs/PATCHNOTES.md b/docs/PATCHNOTES.md index 64aed7f..0306e73 100644 --- a/docs/PATCHNOTES.md +++ b/docs/PATCHNOTES.md @@ -3,6 +3,11 @@ Here you'll find an overview of TipiBOT updates. Latest changes are at the top. Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`). +## v0.3.0 — 2026-08-19 + +- Added `/consumables` — a shop of repeatable, temporary boosts you can buy over and over again (unlike the permanent gear in `/shop`): **Energiajook XL** (1 hour of 2× earnings from `/work`, `/beg` and `/crime`), **XP jook** (1 hour of 2× EXP), and **Kohv** (instantly clears all your cooldowns). Run `/consumables` with no option to browse the menu and see which boosts are still ticking, or pick one to buy and activate it. Buying the same boost again extends its timer instead of wasting it. +- Fixed a broken icon on the "jailed" line in `/cooldowns` + ## v0.2.0 — 2026-07-22 - 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. diff --git a/strings/__init__.py b/strings/__init__.py index 5f34e6c..4c65c3a 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -56,6 +56,8 @@ from .economy import ( QUEST_DESCRIPTIONS, SHOP_UI, ITEM_DESCRIPTIONS, + CONSUMABLES_UI, + CONSUMABLE_DESCRIPTIONS, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -146,6 +148,8 @@ __all__ = [ 'QUEST_DESCRIPTIONS', 'SHOP_UI', 'ITEM_DESCRIPTIONS', + 'CONSUMABLES_UI', + 'CONSUMABLE_DESCRIPTIONS', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index 7cc66e9..e3a9a28 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -73,6 +73,7 @@ CMD: dict[str, str] = { "fishsell": "Müü kalu oma inventarist", "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad", + "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", } # --------------------------------------------------------------------------- @@ -90,6 +91,7 @@ OPT: dict[str, str] = { "give_kasutaja": "Kellele annad?", "give_summa": "Kui palju annad? ('all' = kogu saldo)", "buy_ese": "Eseme nimi (vaata /shop)", + "consumable_ese": "Turgutus, mida osta (tühjaks jättes näeb menüüd)", "rps_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)", "rps_vastane": "Väljakutse teisele mängijale (PvP)", "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", diff --git a/strings/economy.py b/strings/economy.py index 1fdb01e..f8e4557 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -15,6 +15,8 @@ __all__ = [ 'QUEST_DESCRIPTIONS', 'SHOP_UI', 'ITEM_DESCRIPTIONS', + 'CONSUMABLES_UI', + 'CONSUMABLE_DESCRIPTIONS', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -214,6 +216,30 @@ ITEM_DESCRIPTIONS: dict[str, str] = { "kalavork": "Suurem võrk = suuremad kalad. Kõigi kalade haruldus tõuseb ühe astme võrra.", "echolood": "Täpne ehholood näitab kala täpset asukohta. Haukamise aken 2s → 3s.", } + +# --------------------------------------------------------------------------- +# Consumables (repeatable, expiring boosts - a recurring coin sink) +# --------------------------------------------------------------------------- +CONSUMABLE_DESCRIPTIONS: dict[str, str] = { + "energy_xl": "Topeltannus kofeiini. **1 tund**: /work, /beg ja /crime teenivad **2x** rohkem.", + "xp_potion": "Kahtlane roheline jook. **1 tund**: kõik EXP-allikad annavad **2x** rohkem.", + "kohv": "Kange kohv äratab su üles. Nullib kohe kõik ooteajad (work, beg, crime, rob, fish).", +} + +CONSUMABLES_UI: dict[str, str] = { + "title": "☕ Turgutused", + "desc": "Ühekordsed, korduvostetavad boostid. Saldo: {bal} · Osta `/consumables `", + "active_header": "⏳ Aktiivsed boostid", + "active_none": "Ühtegi boosti pole aktiivne.", + "buff_line": "{name} - veel **{time}**", + "kind_earn": "⚡ 2x tulu", + "kind_exp": "✨ 2x EXP", + "bought_title": "{emoji} {name} ostetud!", + "bought_buff": "Boost on aktiivne **{time}**.\nUus saldo: {balance}", + "bought_extended": "Boosti pikendati - aktiivne veel **{time}**.\nUus saldo: {balance}", + "bought_instant": "☕ Kõik ooteajad nullitud!\nUus saldo: {balance}", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "Kõik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_consumables.py b/tests/test_consumables.py new file mode 100644 index 0000000..17047d2 --- /dev/null +++ b/tests/test_consumables.py @@ -0,0 +1,102 @@ +"""Tests for the consumables shop (repeatable, expiring coin sink).""" + +from datetime import datetime, timedelta, timezone + +from core import economy + +from conftest import run + +UID = 4242 + + +def _fixed_now(monkeypatch, dt: datetime): + # _clock is the time seam shared by every economy submodule + monkeypatch.setattr(economy.store, "_clock", lambda: dt) + return dt + + +def _fund(fake_pb, amount: int) -> None: + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = amount + + +class TestBuy: + def test_buy_earn_buff_deducts_and_activates(self, fake_pb): + _fund(fake_pb, 1000) + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert res["ok"] and not res["instant"] + assert res["balance"] == 500 # 1000 - 500 cost + user = run(economy.get_user(UID)) + assert economy.earn_mult(user) == 2.0 + assert economy.exp_buff_mult(user) == 1.0 + + def test_insufficient_funds_rejected(self, fake_pb): + _fund(fake_pb, 100) + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert not res["ok"] and res["reason"] == "insufficient" + assert res["need"] == 400 + assert run(economy.get_user(UID))["balance"] == 100 # unchanged + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, 1000) + fake_pb.record_for(UID)["eco_banned"] = True + res = run(economy.do_buy_consumable(UID, "energy_xl")) + assert not res["ok"] and res["reason"] == "banned" + + def test_unknown_consumable(self, fake_pb): + _fund(fake_pb, 1000) + res = run(economy.do_buy_consumable(UID, "nope")) + assert not res["ok"] and res["reason"] == "not_found" + + +class TestBuffLifecycle: + def test_buff_expires(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + run(economy.do_buy_consumable(UID, "energy_xl")) + assert economy.earn_mult(run(economy.get_user(UID))) == 2.0 + # 61 minutes later the 60-minute buff is gone + _fixed_now(monkeypatch, t0 + timedelta(minutes=61)) + assert economy.earn_mult(run(economy.get_user(UID))) == 1.0 + + def test_rebuy_extends_duration(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 2000) + run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m + _fixed_now(monkeypatch, t0 + timedelta(minutes=30)) + res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets + assert res["extended"] is True + # remaining should be ~90m (30m left + 60m added), not 60m + assert res["remaining"] > timedelta(minutes=85) + + +class TestEffects: + def test_earn_buff_doubles_work(self, fake_pb, monkeypatch): + import random + _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + monkeypatch.setattr(random, "randint", lambda a, b: 50) + monkeypatch.setattr(random, "choice", lambda seq: seq[0]) + monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck + base = run(economy.do_work(UID))["earned"] + fake_pb.record_for(UID)["last_work"] = None # clear cooldown + run(economy.do_buy_consumable(UID, "energy_xl")) + boosted = run(economy.do_work(UID))["earned"] + assert boosted == base * 2 + + def test_exp_buff_doubles_award(self, fake_pb): + _fund(fake_pb, 1000) + run(economy.do_buy_consumable(UID, "xp_potion")) + res = run(economy.award_exp(UID, 10)) + assert res["gained"] == 20 # 10 * 2x exp buff + + def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch): + t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)) + _fund(fake_pb, 1000) + rec = fake_pb.record_for(UID) + rec["last_work"] = t0.isoformat() # on cooldown + res = run(economy.do_buy_consumable(UID, "kohv")) + assert res["ok"] and res["instant"] + user = run(economy.get_user(UID)) + assert user["last_work"] is None # cooldown wiped -> /work is ready + assert economy.store._cooldown_remaining(user, "work") is None