feat/team-divider-placement #4

Open
renkar wants to merge 67 commits from renkar/tipibot:feat/team-divider-placement into master
11 changed files with 371 additions and 5 deletions
Showing only changes of commit a2e1601d0e - Show all commits

View File

@@ -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

View File

@@ -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
View 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"]),
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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"],

View File

@@ -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.

View File

@@ -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',

View File

@@ -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)",

View File

@@ -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
View 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