forked from sass/tipibot
feat(economy): add /consumables shop of repeatable, expiring boosts #5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
146
core/economy/consumables.py
Normal file
146
core/economy/consumables.py
Normal file
@@ -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"]),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 <ese>`",
|
||||
"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.",
|
||||
|
||||
102
tests/test_consumables.py
Normal file
102
tests/test_consumables.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user