forked from sass/tipibot
Compare commits
10 Commits
0ea5580e15
...
feat/team-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1ed28904 | ||
|
|
52002c37fc | ||
| 567a82b9f2 | |||
| f3c69738ef | |||
|
|
a2e1601d0e | ||
|
|
b9b4c7c4c7 | ||
|
|
254992c642 | ||
|
|
191726721c | ||
|
|
cec5ac01a0 | ||
| 5e303fe36f |
@@ -9,6 +9,11 @@ DISCORD_TOKEN=
|
||||
# Google Sheets spreadsheet ID (the long string in the sheet URL)
|
||||
SHEET_ID=your-google-sheet-id-here
|
||||
|
||||
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
|
||||
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
|
||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
||||
TEAM_SHEET_ID=
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -222,7 +222,7 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
|
||||
|
||||
## TipiCOIN Economy
|
||||
|
||||
All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy.py → COIN`.
|
||||
All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy/store.py → COIN`.
|
||||
|
||||
---
|
||||
|
||||
@@ -457,7 +457,7 @@ All items are **permanent** once purchased **except Anticheat**, which expires a
|
||||
Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/blackjack`) accept `"all"` as the amount to wager your entire balance.
|
||||
|
||||
### Custom emoji
|
||||
Change `COIN` in `core/economy.py` to any Discord emoji string:
|
||||
Change `COIN` in `core/economy/store.py` to any Discord emoji string:
|
||||
```python
|
||||
COIN = "<:tipicoin:YOUR_EMOJI_ID>"
|
||||
```
|
||||
@@ -483,7 +483,15 @@ Every slash command invocation is logged with the user ID, display name, and all
|
||||
|
||||
```
|
||||
├── bot.py # Discord client, event handlers, shared helpers; wires command modules together
|
||||
├── strings.py # All user-facing strings (command descriptions, help text, errors)
|
||||
├── strings/ # All user-facing strings, split by domain; re-exported so `import strings` works unchanged
|
||||
│ ├── __init__.py # Re-exports every name from the submodules
|
||||
│ ├── common.py # System messages, embed TITLEs, ERR, CD_MSG, status/send/patchnotes/reminders
|
||||
│ ├── commands.py # CMD + OPT descriptions, HELP_CATEGORIES, HELP_UI
|
||||
│ ├── member.py # /check, member/birthday/channel/economy-setup UI
|
||||
│ ├── economy.py # Income flavour + income/profile/shop/quests/leaderboard/request UI
|
||||
│ ├── admin.py # Admin responses, season reset, prestige
|
||||
│ ├── games.py # Slots, roulette, RPS, blackjack, heist, jailbreak
|
||||
│ └── fishing.py # Fish catalogue, rarities, /fish UI
|
||||
├── config.py # Environment variable loader
|
||||
├── core/
|
||||
│ ├── economy/ # TipiCOIN business logic (re-exported via core/economy/__init__.py)
|
||||
|
||||
46
bot.py
46
bot.py
@@ -23,7 +23,7 @@ import config
|
||||
import strings as S
|
||||
from core import economy, pb_client, sheets
|
||||
from core.admin import is_bot_admin
|
||||
from core.member_sync import SyncResult
|
||||
from core.member_sync import SyncResult, sync_all_team_roles
|
||||
from commands.dev_member_commands import register_dev_member_commands
|
||||
from commands.dev_member_runtime import handle_member_join, run_birthday_daily
|
||||
from commands.economy_admin_commands import register_economy_admin_commands
|
||||
@@ -35,6 +35,7 @@ from commands.economy_prestige_commands import register_prestige_commands
|
||||
from commands.economy_quests_commands import register_economy_quests_commands
|
||||
from commands.economy_profile_commands import register_economy_profile_commands
|
||||
from commands.economy_support_commands import register_economy_support_commands
|
||||
from commands.economy_team_commands import register_economy_team_commands
|
||||
from commands.ops_channel_commands import register_ops_channel_commands
|
||||
from commands.ops_admin_commands import register_ops_admin_commands
|
||||
from commands.info_commands import register_info_commands
|
||||
@@ -336,6 +337,40 @@ async def before_birthday_daily():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
@tasks.loop(hours=1)
|
||||
async def team_sync_hourly():
|
||||
"""Reload the tournament registration sheet and re-apply team roles.
|
||||
|
||||
Economy profile only (the tournament players live in the community guild).
|
||||
Runs the first iteration immediately on start, so this also covers the
|
||||
initial load at boot. No-op when TEAM_SHEET_ID is unset.
|
||||
"""
|
||||
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
|
||||
return
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
log.error("team_sync_hourly: failed to load team sheet: %s", e)
|
||||
return
|
||||
if not rosters:
|
||||
return
|
||||
guild = bot.get_guild(config.GUILD_ID)
|
||||
if guild is None:
|
||||
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
|
||||
return
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
if summary.assigned or summary.removed or summary.created:
|
||||
log.info(
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
|
||||
)
|
||||
|
||||
|
||||
@team_sync_hourly.before_loop
|
||||
async def before_team_sync_hourly():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotating rich presence
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,6 +468,11 @@ async def on_ready():
|
||||
birthday_daily.start()
|
||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
||||
|
||||
# Start hourly tournament team-role sync (economy/community guild)
|
||||
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
|
||||
team_sync_hourly.start()
|
||||
log.info("Team-role sync task started (hourly, from the registration sheet)")
|
||||
|
||||
# Start rotating rich presence
|
||||
if not _rotate_presence.is_running():
|
||||
_rotate_presence.start()
|
||||
@@ -490,6 +530,10 @@ if IS_DEV_PROFILE:
|
||||
has_announced_today=_has_announced_today,
|
||||
mark_announced_today=_mark_announced_today,
|
||||
)
|
||||
else:
|
||||
# Tournament team-role sync lives on the economy/community bot, where the
|
||||
# registered players actually are (see commands/economy_team_commands.py).
|
||||
register_economy_team_commands(tree, bot, log)
|
||||
|
||||
register_ops_admin_commands(
|
||||
tree,
|
||||
|
||||
@@ -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
|
||||
|
||||
87
commands/economy_team_commands.py
Normal file
87
commands/economy_team_commands.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tournament team-role sync for the economy/community guild.
|
||||
|
||||
The tournament participants live in the *economy* (community) guild, not the
|
||||
internal dev guild, so team-role assignment runs here rather than as part of the
|
||||
member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately
|
||||
roster-INDEPENDENT: it matches Discord usernames straight against the separate
|
||||
registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from core import sheets
|
||||
from core.admin import bot_admin_check
|
||||
from core.member_sync import sync_all_team_roles
|
||||
import strings as S
|
||||
|
||||
|
||||
def register_economy_team_commands(
|
||||
tree: app_commands.CommandTree,
|
||||
bot: discord.Client,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
||||
@app_commands.guild_only()
|
||||
@bot_admin_check()
|
||||
async def cmd_teamsync(interaction: discord.Interaction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
||||
)
|
||||
return
|
||||
if not rosters:
|
||||
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
|
||||
return
|
||||
|
||||
summary = await sync_all_team_roles(guild, log)
|
||||
await interaction.followup.send(_format_summary(summary), ephemeral=True)
|
||||
log.info(
|
||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.scanned,
|
||||
summary.assigned,
|
||||
summary.removed,
|
||||
len(summary.created),
|
||||
len(summary.errors),
|
||||
)
|
||||
|
||||
|
||||
def _format_summary(summary) -> str:
|
||||
lines = [
|
||||
S.TEAMSYNC_UI["done"],
|
||||
S.TEAMSYNC_UI["scanned"].format(count=summary.scanned),
|
||||
S.TEAMSYNC_UI["assigned"].format(count=summary.assigned),
|
||||
S.TEAMSYNC_UI["removed"].format(count=summary.removed),
|
||||
]
|
||||
if summary.created:
|
||||
# A team can be created only once, but the same role could surface for
|
||||
# several members in one run - de-dupe for the report.
|
||||
unique = list(dict.fromkeys(summary.created))
|
||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
||||
if summary.errors:
|
||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
if summary.changes:
|
||||
shown = summary.changes[:20]
|
||||
text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown)
|
||||
if len(summary.changes) > 20:
|
||||
text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20)
|
||||
else:
|
||||
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
||||
|
||||
return text
|
||||
@@ -23,6 +23,9 @@ DISCORD_TOKEN = (
|
||||
) or _LEGACY_DISCORD_TOKEN
|
||||
|
||||
SHEET_ID = os.getenv("SHEET_ID")
|
||||
# Separate spreadsheet holding the tournament team registrations (Team Name +
|
||||
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
|
||||
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
|
||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
||||
|
||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@ QUESTS_DAILY: dict[str, QuestDef] = {
|
||||
"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},
|
||||
"give200": {"stat": "total_given", "goal": 200, "coins": 250, "exp": 20},
|
||||
}
|
||||
|
||||
QUESTS_WEEKLY: dict[str, QuestDef] = {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -48,7 +48,6 @@ class SyncResult:
|
||||
"""Tracks what happened during a sync operation."""
|
||||
nickname_changed: bool = False
|
||||
roles_added: list[str] = field(default_factory=list)
|
||||
roles_removed: list[str] = field(default_factory=list)
|
||||
birthday_soon: bool = False
|
||||
birthday_today: bool = False
|
||||
not_found: bool = False
|
||||
@@ -57,7 +56,31 @@ class SyncResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self.nickname_changed or self.roles_added or self.roles_removed
|
||||
return self.nickname_changed or self.roles_added
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamSyncResult:
|
||||
"""What happened when syncing one member's tournament team role."""
|
||||
added: str | None = None # team role name granted, if any
|
||||
removed: list[str] = field(default_factory=list) # stale team roles taken away
|
||||
created: str | None = None # team role name auto-created in the guild, if any
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.added or self.removed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamSyncSummary:
|
||||
"""Aggregate outcome of a whole-guild team-role sync."""
|
||||
scanned: int = 0
|
||||
assigned: int = 0
|
||||
removed: int = 0
|
||||
created: list[str] = field(default_factory=list)
|
||||
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _format_nickname(full_name: str) -> str:
|
||||
@@ -224,6 +247,101 @@ async def sync_member(
|
||||
return result
|
||||
|
||||
|
||||
async def sync_team_role(
|
||||
member: discord.Member,
|
||||
guild: discord.Guild,
|
||||
) -> TeamSyncResult:
|
||||
"""Give one member their tournament team role from the registration sheet.
|
||||
|
||||
Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the
|
||||
internal member sheet at all. It matches the member's Discord username
|
||||
against the team sheet caches (populated by ``sheets.refresh_teams``) and:
|
||||
|
||||
* grants the role for the team they're registered on (auto-creating that
|
||||
role in the guild when it does not exist yet);
|
||||
* removes any *other* team role they still carry (left / switched teams).
|
||||
|
||||
Only role NAMES present in the team sheet are ever added or removed, so no
|
||||
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
|
||||
are empty and this is a no-op returning an unchanged result.
|
||||
"""
|
||||
result = TeamSyncResult()
|
||||
|
||||
team_name = sheets.get_team_for_username(member.name)
|
||||
all_teams = sheets.all_team_names()
|
||||
if not all_teams:
|
||||
return result # feature switched off (no team sheet loaded)
|
||||
|
||||
desired: discord.Role | None = None
|
||||
if team_name:
|
||||
desired = discord.utils.get(guild.roles, name=team_name)
|
||||
if desired is None:
|
||||
try:
|
||||
desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
|
||||
result.created = team_name
|
||||
log.info("Created team role %r for %s", team_name, member)
|
||||
except discord.Forbidden:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
|
||||
|
||||
# Team roles held but no longer registered for (switched teams / dropped out).
|
||||
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
|
||||
|
||||
if desired is not None and desired not in member.roles:
|
||||
try:
|
||||
await member.add_roles(desired, reason="Team sync")
|
||||
result.added = desired.name
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to add team role for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}")
|
||||
|
||||
if to_remove:
|
||||
try:
|
||||
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
|
||||
result.removed = [r.name for r in to_remove]
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to remove team roles for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def sync_all_team_roles(
|
||||
guild: discord.Guild,
|
||||
log: logging.Logger = log,
|
||||
) -> TeamSyncSummary:
|
||||
"""Run :func:`sync_team_role` for every human member of ``guild``.
|
||||
|
||||
Assumes the team caches are already fresh (caller runs ``refresh_teams``
|
||||
first). Returns an aggregate summary for reporting.
|
||||
"""
|
||||
summary = TeamSyncSummary()
|
||||
for member in guild.members:
|
||||
if member.bot:
|
||||
continue
|
||||
summary.scanned += 1
|
||||
res = await sync_team_role(member, guild)
|
||||
if res.created:
|
||||
summary.created.append(res.created)
|
||||
if res.errors:
|
||||
summary.errors.extend(res.errors)
|
||||
if res.added:
|
||||
summary.assigned += 1
|
||||
if res.removed:
|
||||
summary.removed += len(res.removed)
|
||||
if res.changed:
|
||||
bits: list[str] = []
|
||||
if res.added:
|
||||
bits.append(f"+{res.added}")
|
||||
if res.removed:
|
||||
bits.append("-" + ", -".join(res.removed))
|
||||
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
||||
return summary
|
||||
|
||||
|
||||
async def announce_birthday(
|
||||
member: discord.Member,
|
||||
bot: discord.Client,
|
||||
|
||||
161
core/sheets.py
161
core/sheets.py
@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import gspread
|
||||
from google.oauth2.service_account import Credentials
|
||||
@@ -250,3 +251,163 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
||||
async def add_new_member_row(username: str, discord_id: int) -> None:
|
||||
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
||||
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unlike the member roster this sheet is NOT a single clean table: it stacks
|
||||
# several game sections (CS2, LoL, ...) - each with merged title/description
|
||||
# rows, its own header row, and a block of team rows - across one or more tabs.
|
||||
# So we read raw cell values (get_all_values) and scan for header rows rather
|
||||
# than relying on get_all_records, which requires one rectangular table.
|
||||
#
|
||||
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
|
||||
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
|
||||
# players' Discord usernames; the citizenship is used only as a delimiter
|
||||
# (a nickname may itself contain commas) and then discarded.
|
||||
# ===========================================================================
|
||||
|
||||
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
|
||||
# lineup cell into individual players, then thrown away.
|
||||
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
||||
|
||||
_TEAM_NAME_HEADER = "team name"
|
||||
_LINEUP_HEADER_PREFIX = "lineup"
|
||||
|
||||
# Team-sheet caches (mirrors the member-roster cache above)
|
||||
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
||||
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
||||
_team_names: set[str] = set() # universe of all team names
|
||||
|
||||
|
||||
def parse_lineup(cell: str) -> list[str]:
|
||||
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
|
||||
|
||||
Players are delimited by their trailing citizenship marker, e.g.
|
||||
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
|
||||
Splitting on the marker (not on commas) keeps nicknames that themselves
|
||||
contain commas or semicolons intact. Falls back to comma-splitting when a
|
||||
cell carries no citizenship markers at all.
|
||||
"""
|
||||
cell = str(cell).strip()
|
||||
if not cell:
|
||||
return []
|
||||
names: list[str] = []
|
||||
last = 0
|
||||
matched = False
|
||||
for m in _CITIZENSHIP_RE.finditer(cell):
|
||||
matched = True
|
||||
chunk = cell[last:m.start()].strip().strip(",;").strip()
|
||||
if chunk:
|
||||
names.append(chunk)
|
||||
last = m.end()
|
||||
if not matched:
|
||||
return [p.strip() for p in cell.split(",") if p.strip()]
|
||||
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
|
||||
if tail:
|
||||
names.append(tail)
|
||||
return names
|
||||
|
||||
|
||||
def _find_col(row: list, matches) -> int | None:
|
||||
for idx, cell in enumerate(row):
|
||||
if matches(str(cell)):
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _cell(row: list, idx: int) -> str:
|
||||
return str(row[idx]) if 0 <= idx < len(row) else ""
|
||||
|
||||
|
||||
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||
"""Extract {team_name: [nickname, ...]} from a tab's raw rows.
|
||||
|
||||
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
|
||||
column, then reads the rows beneath it (using that section's own column
|
||||
positions) until the team-name column goes blank or a new header appears.
|
||||
Handles multiple stacked sections with differing layouts in one tab.
|
||||
"""
|
||||
rosters: dict[str, list[str]] = {}
|
||||
i, n = 0, len(rows)
|
||||
while i < n:
|
||||
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
||||
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
|
||||
if name_col is None or lineup_col is None:
|
||||
i += 1
|
||||
continue
|
||||
i += 1 # move past the header into the data block
|
||||
while i < n:
|
||||
team = _cell(rows[i], name_col).strip()
|
||||
if not team or team.lower() == _TEAM_NAME_HEADER:
|
||||
break # blank team-name (or a new header) ends this section
|
||||
players = parse_lineup(_cell(rows[i], lineup_col))
|
||||
if players:
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
i += 1
|
||||
return rosters
|
||||
|
||||
|
||||
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
||||
"""Invert {team: [usernames]} into {normalized username: team}.
|
||||
|
||||
If the same username appears on two teams the last one wins and a warning
|
||||
is logged (a person is expected to be on exactly one team).
|
||||
"""
|
||||
index: dict[str, str] = {}
|
||||
for team, players in rosters.items():
|
||||
for player in players:
|
||||
key = player.strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
if key in index and index[key] != team:
|
||||
log.warning(
|
||||
"Player %r appears on both %r and %r; using %r",
|
||||
player, index[key], team, team,
|
||||
)
|
||||
index[key] = team
|
||||
return index
|
||||
|
||||
|
||||
def _refresh_teams_sync() -> dict[str, list[str]]:
|
||||
global _team_roster, _team_by_username, _team_names
|
||||
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||
client = gspread.authorize(creds)
|
||||
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
||||
|
||||
rosters: dict[str, list[str]] = {}
|
||||
for ws in spreadsheet.worksheets():
|
||||
for team, players in parse_team_rosters(ws.get_all_values()).items():
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
|
||||
_team_roster = rosters
|
||||
_team_by_username = build_username_index(rosters)
|
||||
_team_names = set(rosters)
|
||||
return rosters
|
||||
|
||||
|
||||
async def refresh_teams() -> dict[str, list[str]]:
|
||||
"""Reload the team registration sheet into the in-memory team caches.
|
||||
|
||||
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
|
||||
feature can be left switched off without touching sync behaviour.
|
||||
"""
|
||||
if not config.TEAM_SHEET_ID:
|
||||
return {}
|
||||
return await asyncio.to_thread(_refresh_teams_sync)
|
||||
|
||||
|
||||
def get_team_for_username(username: str) -> str | None:
|
||||
"""Return the team a Discord username is registered on, or None."""
|
||||
return _team_by_username.get(str(username).strip().lower())
|
||||
|
||||
|
||||
def all_team_names() -> set[str]:
|
||||
"""Every team name known from the registration sheet (the role universe)."""
|
||||
return set(_team_names)
|
||||
|
||||
|
||||
def get_team_rosters() -> dict[str, list[str]]:
|
||||
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
||||
return _team_roster
|
||||
|
||||
@@ -9,14 +9,14 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `bot.py` | Discord client, event handlers (`on_ready`, `on_member_join`, ...), background tasks (presence rotation, daily birthday loop), shared helpers (`_award_exp`, `_maybe_remind`, `_parse_amount`, `_PAUSED`), and `register_*_commands(...)` wiring for every command module |
|
||||
| `strings.py` | **Single source of truth for all user-facing text.** Edit here to change any message. |
|
||||
| `strings/` | **Single source of truth for all user-facing text**, split into domain submodules (`common`, `commands`, `member`, `economy`, `admin`, `games`, `fishing`) and re-exported via `strings/__init__.py` so `import strings` / `strings.NAME` works unchanged. Edit the relevant submodule to change any message. |
|
||||
| `config.py` | Environment variables (TOKEN, GUILD_ID, PB_URL, etc.) |
|
||||
|
||||
### `core/` - domain logic, no Discord coupling
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `core/economy.py` | All economy business logic (`do_daily`, `do_work`, ...), data model, constants (SHOP, COOLDOWNS, LEVEL_ROLES, EXP_REWARDS, JAIL_DURATION, ...) |
|
||||
| `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `house.py`, `admin.py` |
|
||||
| `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||
| `core/sheets.py` | Google Sheets integration (member sync) |
|
||||
| `core/member_sync.py` | Birthday/member sync helpers |
|
||||
@@ -57,19 +57,19 @@ Pick the `commands/economy_*_commands.py` file that matches the new command's ca
|
||||
|
||||
Checklist - do all of these, in order:
|
||||
|
||||
1. **`core/economy.py`** - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging
|
||||
2. **`core/economy.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||
3. **`core/economy.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||
4. **`strings.py` `CMD`** - add the slash command description
|
||||
5. **`strings.py` `OPT`** - add any parameter descriptions
|
||||
6. **`strings.py` `TITLE`** - add embed title(s) for success/fail states
|
||||
7. **`strings.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
|
||||
8. **`strings.py` `CD_MSG`** - add cooldown message if command has a cooldown
|
||||
9. **`strings.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||
1. **`core/economy/<area>.py`** (e.g. `income.py`, `gambling.py`, `fishing.py`) - add the `do_<cmd>` async function with cooldown check, logic, `_commit`, and `_txn` logging (`get_user`/`_commit`/`_txn` live in `store.py`)
|
||||
2. **`core/economy/store.py`** - add the cooldown to `COOLDOWNS` dict if it has one
|
||||
3. **`core/economy/levels.py`** - add the EXP reward to `EXP_REWARDS` dict
|
||||
4. **`strings/commands.py` `CMD`** - add the slash command description
|
||||
5. **`strings/commands.py` `OPT`** - add any parameter descriptions
|
||||
6. **`strings/common.py` `TITLE`** - add embed title(s) for success/fail states
|
||||
7. **`strings/common.py` `ERR`** - add any error messages (banned, cooldown uses `CD_MSG`, jailed uses `CD_MSG["jailed"]`)
|
||||
8. **`strings/common.py` `CD_MSG`** - add cooldown message if command has a cooldown
|
||||
9. **`strings/commands.py` `HELP_CATEGORIES["tipibot"]["fields"]`** - add the command to the help embed
|
||||
10. **`commands/economy_<group>_commands.py`** - inside `register_*_commands`, add `@tree.command(name="<cmd>", ...)` `cmd_<name>`; handle all `res["reason"]` cases
|
||||
11. **`commands/economy_<group>_commands.py`** - call `maybe_remind(user_id, "<cmd>")` if the command has a cooldown and reminders make sense (the helper is passed in via the `register_*` signature)
|
||||
12. **`commands/economy_<group>_commands.py`** - call `await award_exp(interaction, economy.EXP_REWARDS["<cmd>"])` on success
|
||||
13. **`strings.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
||||
13. **`strings/common.py` `REMINDER_OPTS`** - add a reminder option if the command needs one
|
||||
14. **`bot.py` `_maybe_remind`** - if the command has an item-modified cooldown, add an `elif` branch (this helper still lives in `bot.py` and is shared across all command modules)
|
||||
|
||||
---
|
||||
@@ -78,13 +78,13 @@ Checklist - do all of these, in order:
|
||||
|
||||
Checklist:
|
||||
|
||||
1. **`core/economy.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
||||
2. **`core/economy.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
||||
3. **`core/economy.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
|
||||
4. **`strings.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
|
||||
5. **`strings.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||
1. **`core/economy/shop.py` `SHOP`** - add the item dict `{name, emoji, cost, description: strings.ITEM_DESCRIPTIONS["key"]}`
|
||||
2. **`core/economy/shop.py` `SHOP_TIERS`** - add the key to the correct tier list (1/2/3)
|
||||
3. **`core/economy/shop.py` `SHOP_LEVEL_REQ`** - add minimum level if it is T2 (≥10) or T3 (≥20)
|
||||
4. **`strings/economy.py` `ITEM_DESCRIPTIONS`** - add the item description (Estonian flavour + English effect)
|
||||
5. **`strings/commands.py` `HELP_CATEGORIES["shop"]["fields"]`** - add display entry (sorted by cost)
|
||||
6. If the item modifies a cooldown:
|
||||
- **`core/economy.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
||||
- **`core/economy/<area>.py`** - add the `if "item" in user["items"]` branch in the relevant `do_<cmd>` function
|
||||
- **`bot.py` `_maybe_remind`** - add `elif cmd == "<cmd>" and "<item>" in items:` branch with the new delay
|
||||
- **`commands/economy_profile_commands.py` `cmd_cooldowns`** - add the item annotation to the relevant status line
|
||||
|
||||
@@ -92,7 +92,7 @@ Checklist:
|
||||
|
||||
## Adding a New Level Role
|
||||
|
||||
1. **`core/economy.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
||||
1. **`core/economy/levels.py` `LEVEL_ROLES`** - add `(min_level, "RoleName")` in descending level order (highest first)
|
||||
2. **`bot.py` `_ensure_level_role`** - no changes needed (uses `LEVEL_ROLES` dynamically)
|
||||
3. Run **`/economysetup`** in the server to create the role and set its position
|
||||
|
||||
@@ -100,8 +100,8 @@ Checklist:
|
||||
|
||||
## Adding a New Admin Command
|
||||
|
||||
1. **`strings.py` `CMD`** - add `"[Admin] ..."` description
|
||||
2. **`strings.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
||||
1. **`strings/commands.py` `CMD`** - add `"[Admin] ..."` description
|
||||
2. **`strings/commands.py` `HELP_CATEGORIES["admin"]["fields"]`** - add the entry
|
||||
3. **`commands/economy_admin_commands.py`** (or `commands/ops_admin_commands.py` for non-economy ops) - add the handler with `@app_commands.default_permissions(manage_guild=True)` and `@app_commands.guild_only()`
|
||||
|
||||
---
|
||||
@@ -110,7 +110,7 @@ Checklist:
|
||||
|
||||
### Storage
|
||||
|
||||
All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy.py` calls `get_user()` → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
|
||||
All economy state is stored in **PocketBase** (`economy_users` collection). `core/pb_client.py` owns all reads/writes. Each `do_*` function in `core/economy/` calls `get_user()` (from `store.py`) → mutates the local dict → calls `_commit()`. `_commit` does a `PATCH` to PocketBase.
|
||||
|
||||
### Currency & Income Sources
|
||||
|
||||
@@ -141,10 +141,10 @@ Commands that accept a coin amount (`/give`, `/roulette`, `/rps`, `/slots`, `/bl
|
||||
- `/jailbreak`: 3 dice rolls, need doubles to escape free. On fail - bail = 20-30% of balance, min 350⬡. If balance < 350⬡, player stays jailed until timer.
|
||||
- **Blocked while jailed**: `/work`, `/beg`, `/crime`, `/rob`, `/give` (checked in `do_*` functions via `_is_jailed`)
|
||||
|
||||
### EXP Rewards (from `EXP_REWARDS` in `core/economy.py`)
|
||||
### EXP Rewards (from `EXP_REWARDS` in `core/economy/levels.py`)
|
||||
EXP is awarded on every successful command use. Level formula: `level = max(1, floor(sqrt(exp / 6)))` (see `get_level` / `exp_for_level`). Thresholds: Level 5 = 150 EXP, Level 10 = 600, Level 20 = 2 400, Level 30 = 5 400.
|
||||
|
||||
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25).
|
||||
Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FISH_CATALOGUE` (common 2–3, uncommon 6–7, rare 10, epic 14–15, legendary 25).
|
||||
|
||||
---
|
||||
|
||||
@@ -179,49 +179,51 @@ Role assignment:
|
||||
| T3 | 20 | monitor_360, karikas, gaming_tool |
|
||||
|
||||
Shop display is sorted by cost (ascending) within each tier.
|
||||
The `SHOP_LEVEL_REQ` dict in `core/economy.py` controls per-item lock thresholds.
|
||||
The `SHOP_LEVEL_REQ` dict in `core/economy/shop.py` controls per-item lock thresholds.
|
||||
|
||||
---
|
||||
|
||||
## strings.py Organisation
|
||||
## strings/ Organisation
|
||||
|
||||
Imported as `import strings as S` everywhere. Dicts are read from `bot.py` and from every `commands/*.py` module.
|
||||
Imported as `import strings as S` everywhere. `strings/` is a package: the names below live in domain submodules and are re-exported from `strings/__init__.py`, so `S.CMD`, `S.ERR`, etc. resolve unchanged regardless of which submodule they live in. Dicts are read from `bot.py` and from every `commands/*.py` module. Edit the submodule shown in the **Module** column.
|
||||
|
||||
| Section | Dict | Typical usage |
|
||||
|---|---|---|
|
||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | Randomised descriptions |
|
||||
| Command descriptions | `CMD["key"]` | `@tree.command(description=S.CMD["key"])` |
|
||||
| Parameter descriptions | `OPT["key"]` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||
| Help embed | `HELP_CATEGORIES["cat"]` | `cmd_help` (in `bot.py`) |
|
||||
| Banned message | `MSG_BANNED` | All banned checks |
|
||||
| Maintenance mode | `MSG_MAINTENANCE` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
|
||||
| Reminder options | `REMINDER_OPTS` | `RemindersSelect` dropdown |
|
||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
||||
| Embed titles | `TITLE["key"]` | `discord.Embed(title=S.TITLE["key"])` |
|
||||
| Error messages | `ERR["key"]` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
|
||||
| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
|
||||
| Shop UI | `SHOP_UI["key"]` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `core/economy.py` `SHOP[key]["description"]` |
|
||||
| Patch notes UI | `PATCHNOTES_UI["key"]` | `commands/info_commands.py` (`/patchnotes`) |
|
||||
| Section | Dict | Module | Typical usage |
|
||||
|---|---|---|---|
|
||||
| Flavour text | `WORK_JOBS`, `BEG_LINES`, `CRIME_WIN`, `CRIME_LOSE` | `economy.py` | Randomised descriptions |
|
||||
| Command descriptions | `CMD["key"]` | `commands.py` | `@tree.command(description=S.CMD["key"])` |
|
||||
| Parameter descriptions | `OPT["key"]` | `commands.py` | `@app_commands.describe(param=S.OPT["key"])` |
|
||||
| Help embed | `HELP_CATEGORIES["cat"]` | `commands.py` | `cmd_help` (in `bot.py`) |
|
||||
| Banned message | `MSG_BANNED` | `common.py` | All banned checks |
|
||||
| Maintenance mode | `MSG_MAINTENANCE` | `common.py` | Shown when `_PAUSED=True` in `bot.py` (toggled by `/pause` in `commands/ops_admin_commands.py`) |
|
||||
| Reminder options | `REMINDER_OPTS` | `common.py` | `RemindersSelect` dropdown |
|
||||
| Slots outcomes | `SLOTS_TIERS["tier"]` → `(title, color)` | `games.py` | `cmd_slots` (in `commands/economy_games_commands.py`) |
|
||||
| Embed titles | `TITLE["key"]` | `common.py` | `discord.Embed(title=S.TITLE["key"])` |
|
||||
| Error messages | `ERR["key"]` | `common.py` | `send_message(S.ERR["key"])` - use `.format(**kwargs)` for dynamic parts |
|
||||
| Cooldown messages | `CD_MSG["cmd"].format(ts=cd_ts(...))` | `common.py` | Cooldown responses (`cd_ts` helper passed in by `bot.py`) |
|
||||
| Shop UI | `SHOP_UI["key"]` | `economy.py` | `_shop_embed` (in `commands/economy_support_commands.py`) |
|
||||
| Item descriptions | `ITEM_DESCRIPTIONS["item_key"]` | `economy.py` | `core/economy/shop.py` `SHOP[key]["description"]` |
|
||||
| Patch notes UI | `PATCHNOTES_UI["key"]` | `common.py` | `commands/info_commands.py` (`/patchnotes`) |
|
||||
|
||||
---
|
||||
|
||||
## Constants Location Quick-Reference
|
||||
|
||||
| Constant | File | Description |
|
||||
All are re-exported from `core/economy/__init__.py`, so code reads them as `economy.<NAME>` regardless of which submodule defines them. Edit the file in the **Defined in** column.
|
||||
|
||||
| Constant | Defined in | Description |
|
||||
|---|---|---|
|
||||
| `SHOP` | `core/economy.py` | All shop items (name, emoji, cost, description) |
|
||||
| `SHOP_TIERS` | `core/economy.py` | Which items are in T1/T2/T3 |
|
||||
| `SHOP_LEVEL_REQ` | `core/economy.py` | Min level per item |
|
||||
| `COOLDOWNS` | `core/economy.py` | Base cooldown per command |
|
||||
| `JAIL_DURATION` | `core/economy.py` | How long jail lasts |
|
||||
| `LEVEL_ROLES` | `core/economy.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||
| `ECONOMY_ROLE` | `core/economy.py` | Name of the base economy participation role |
|
||||
| `EXP_REWARDS` | `core/economy.py` | EXP per command |
|
||||
| `FISH` | `core/economy.py` | Fish species table (rarity, weight, coins, exp) |
|
||||
| `HOUSE_ID` | `core/economy.py` | Bot's user ID (house account for /rob) |
|
||||
| `MIN_BAIL` | `core/economy.py` | Minimum bail payment (350⬡) |
|
||||
| `COIN` | `core/economy.py` | The coin emoji string |
|
||||
| `SHOP` | `core/economy/shop.py` | All shop items (name, emoji, cost, description) |
|
||||
| `SHOP_TIERS` | `core/economy/shop.py` | Which items are in T1/T2/T3 |
|
||||
| `SHOP_LEVEL_REQ` | `core/economy/shop.py` | Min level per item |
|
||||
| `COOLDOWNS` | `core/economy/store.py` | Base cooldown per command |
|
||||
| `JAIL_DURATION` | `core/economy/store.py` | How long jail lasts |
|
||||
| `LEVEL_ROLES` | `core/economy/levels.py` | `[(min_level, "RoleName"), ...]` highest first |
|
||||
| `ECONOMY_ROLE` | `core/economy/levels.py` | Name of the base economy participation role |
|
||||
| `EXP_REWARDS` | `core/economy/levels.py` | EXP per command |
|
||||
| `FISH_CATALOGUE` | `core/economy/fishing.py` | Fish species table (rarity, weight, coins, exp) |
|
||||
| `HOUSE_ID` | `core/economy/house.py` | Bot's user ID (house account for /rob) |
|
||||
| `MIN_BAIL` | `core/economy/jail.py` | Minimum bail payment (350⬡) |
|
||||
| `COIN` | `core/economy/store.py` | The coin emoji string |
|
||||
| `_PAUSED` | `bot.py` | In-memory maintenance flag; toggled by `/pause`; blocks all non-admin commands |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,6 +44,7 @@ from .member import (
|
||||
BIRTHDAY_UI,
|
||||
BIRTHDAY_MONTHS,
|
||||
CHECK_UI,
|
||||
TEAMSYNC_UI,
|
||||
)
|
||||
|
||||
from .economy import (
|
||||
@@ -56,6 +57,8 @@ from .economy import (
|
||||
QUEST_DESCRIPTIONS,
|
||||
SHOP_UI,
|
||||
ITEM_DESCRIPTIONS,
|
||||
CONSUMABLES_UI,
|
||||
CONSUMABLE_DESCRIPTIONS,
|
||||
JAILED_UI,
|
||||
SHOP_BTN,
|
||||
DAILY_UI,
|
||||
@@ -137,6 +140,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
'WORK_JOBS',
|
||||
'BEG_LINES',
|
||||
'BEG_JAIL_LINES',
|
||||
@@ -146,6 +150,8 @@ __all__ = [
|
||||
'QUEST_DESCRIPTIONS',
|
||||
'SHOP_UI',
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
|
||||
@@ -24,6 +24,7 @@ CMD: dict[str, str] = {
|
||||
"check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed",
|
||||
"sync": "Sünkroniseeri käsklused Discordi serveriga",
|
||||
"member": "Näita liikme andmeid tabelist",
|
||||
"teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist",
|
||||
"restart": "Tee taaskäivitus botile",
|
||||
"shutdown": "Lülita bot välja (ilma taaskäivituseta)",
|
||||
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
|
||||
@@ -73,6 +74,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 +92,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.",
|
||||
@@ -314,7 +340,7 @@ COOLDOWNS_UI: dict[str, str] = {
|
||||
"note_monitor": " *(monitor: 40min)*",
|
||||
"note_hiirematt": " *(hiirematt: 3min)*",
|
||||
"note_ussipurk": " *(ussipurk: 90s)*",
|
||||
"jailed": "\n<EFBFBD> **Vanglas** - vabaneb <t:{ts}:R>",
|
||||
"jailed": "\n🔒 **Vanglas** - vabaneb <t:{ts}:R>",
|
||||
"jail_expired": "\n🔓 Vangla lõppes",
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ __all__ = [
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,3 +95,22 @@ CHECK_UI: dict[str, str] = {
|
||||
"detail_changed": "🔧 **{name}**: {parts}",
|
||||
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /teamsync UI strings (tournament team-role sync from the registration sheet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEAMSYNC_UI: dict[str, str] = {
|
||||
"disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).",
|
||||
"refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}",
|
||||
"done": "**Tiimide sünkroonimine lõpetatud!**",
|
||||
"scanned": "👥 Kontrollitud liikmeid: {count}",
|
||||
"assigned": "✅ Tiimirolle antud: {count}",
|
||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||
"changes_header": "**Muudatused:**",
|
||||
"changes_more": "... ja {count} rohkem",
|
||||
}
|
||||
|
||||
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
|
||||
77
tests/test_strings.py
Normal file
77
tests/test_strings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Guard tests for the strings/ package.
|
||||
|
||||
strings/ is split into domain submodules whose names are re-exported from
|
||||
strings/__init__.py so callers keep using `strings.NAME`. It's easy to add a
|
||||
constant to a submodule and forget to re-export it (or to shadow a name across
|
||||
two submodules) - both would only surface as a runtime crash in a command.
|
||||
These tests catch that at test time instead.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
import strings
|
||||
|
||||
# Auto-discover submodules so a newly added one is covered without editing this.
|
||||
SUBMODULES = sorted(m.name for m in pkgutil.iter_modules(strings.__path__))
|
||||
|
||||
|
||||
def _submodule(name):
|
||||
return importlib.import_module(f"strings.{name}")
|
||||
|
||||
|
||||
class TestStringsPackage:
|
||||
def test_submodules_discovered(self):
|
||||
# Sanity: the split actually produced multiple domain modules.
|
||||
assert len(SUBMODULES) >= 2, SUBMODULES
|
||||
|
||||
def test_every_submodule_declares_all(self):
|
||||
for name in SUBMODULES:
|
||||
mod = _submodule(name)
|
||||
assert hasattr(mod, "__all__"), f"strings.{name} is missing __all__"
|
||||
|
||||
def test_all_entries_exist_in_their_submodule(self):
|
||||
for name in SUBMODULES:
|
||||
mod = _submodule(name)
|
||||
for const in mod.__all__:
|
||||
assert hasattr(mod, const), (
|
||||
f"{const} is listed in strings.{name}.__all__ "
|
||||
f"but not defined in that module"
|
||||
)
|
||||
|
||||
def test_every_name_is_reexported_from_package(self):
|
||||
for name in SUBMODULES:
|
||||
mod = _submodule(name)
|
||||
for const in mod.__all__:
|
||||
assert hasattr(strings, const), (
|
||||
f"{const} is defined in strings.{name} but not re-exported "
|
||||
f"from strings/__init__.py - add it to the imports there"
|
||||
)
|
||||
assert getattr(strings, const) is getattr(mod, const), (
|
||||
f"strings.{const} is not the same object as strings.{name}.{const}"
|
||||
)
|
||||
|
||||
def test_no_name_defined_in_two_submodules(self):
|
||||
origin = {}
|
||||
for name in SUBMODULES:
|
||||
for const in _submodule(name).__all__:
|
||||
assert const not in origin, (
|
||||
f"{const} is defined in both strings.{origin[const]} "
|
||||
f"and strings.{name}"
|
||||
)
|
||||
origin[const] = name
|
||||
|
||||
def test_package_all_matches_submodule_union(self):
|
||||
union = set()
|
||||
for name in SUBMODULES:
|
||||
union |= set(_submodule(name).__all__)
|
||||
assert set(strings.__all__) == union, {
|
||||
"missing_from_package_all": sorted(union - set(strings.__all__)),
|
||||
"extra_in_package_all": sorted(set(strings.__all__) - union),
|
||||
}
|
||||
|
||||
def test_public_constants_all_declared(self):
|
||||
# Every UPPER_CASE constant exposed on the package is accounted for in
|
||||
# __all__ (E, the emoji helper, is an implementation detail, not a string).
|
||||
public = {n for n in vars(strings) if n.isupper() and n != "E"}
|
||||
assert public == set(strings.__all__)
|
||||
224
tests/test_team_sync.py
Normal file
224
tests/test_team_sync.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Tests for team-role sync from the tournament registration sheet.
|
||||
|
||||
Covers the risky parsing (turning a messy, multi-section, merged-cell sheet
|
||||
into {team: [discord usernames]}) and the roster-independent add/remove/
|
||||
auto-create behaviour of sync_team_role, using lightweight fakes for discord
|
||||
+ the sheets cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from core import member_sync, sheets # noqa: E402
|
||||
from tests.conftest import run # noqa: E402
|
||||
|
||||
|
||||
# --- parse_lineup ----------------------------------------------------------
|
||||
|
||||
def test_parse_lineup_strips_citizenship_and_splits():
|
||||
cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"]
|
||||
|
||||
|
||||
def test_parse_lineup_keeps_names_containing_commas_and_semicolons():
|
||||
# A single player's descriptive name contains a ';' - must stay one name.
|
||||
cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)"
|
||||
assert sheets.parse_lineup(cell) == [
|
||||
"Onu Klaus ; vahepeal ka mõni teine tegelane",
|
||||
"Lurban",
|
||||
]
|
||||
|
||||
|
||||
def test_parse_lineup_handles_odd_usernames():
|
||||
cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"]
|
||||
|
||||
|
||||
def test_parse_lineup_mixed_citizenship():
|
||||
cell = "imp (LAT), milteg (EST), Freesies (EST)"
|
||||
assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"]
|
||||
|
||||
|
||||
def test_parse_lineup_empty():
|
||||
assert sheets.parse_lineup("") == []
|
||||
assert sheets.parse_lineup(" ") == []
|
||||
|
||||
|
||||
def test_parse_lineup_fallback_without_citizenship():
|
||||
assert sheets.parse_lineup("alice, bob") == ["alice", "bob"]
|
||||
|
||||
|
||||
# --- parse_team_rosters (multi-section sheet) ------------------------------
|
||||
|
||||
# Mirrors the real sheet: merged title rows, a header row, team rows, a blank
|
||||
# separator, then a SECOND section with a different column count.
|
||||
SHEET_ROWS = [
|
||||
["", "", "", "", "", "", ""],
|
||||
["name", "members", "vrs ranking", "registration_date", "game", "", ""],
|
||||
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking",
|
||||
"Registration Timestamp", "Status", "Participation confirmed?"],
|
||||
["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"],
|
||||
["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"],
|
||||
["", "", "", "", "", "", ""],
|
||||
["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)",
|
||||
"Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"],
|
||||
["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""],
|
||||
["", "", "", "", ""],
|
||||
]
|
||||
|
||||
|
||||
def test_parse_team_rosters_multiple_sections():
|
||||
rosters = sheets.parse_team_rosters(SHEET_ROWS)
|
||||
assert rosters == {
|
||||
"Piirivalvurid": ["TFT", "nqmm"],
|
||||
"GENESIS": ["kapa", "neaQ"],
|
||||
"Pushing 30s": ["Onu Klaus", "Lurban"],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_team_rosters_ignores_non_table_content():
|
||||
# No header row anywhere -> nothing extracted, no crash.
|
||||
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
|
||||
|
||||
|
||||
def test_build_username_index_is_case_insensitive():
|
||||
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
|
||||
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
|
||||
|
||||
|
||||
# --- sync_team_role behaviour (roster-independent) -------------------------
|
||||
|
||||
class FakeRole:
|
||||
def __init__(self, rid: int, name: str):
|
||||
self.id = rid
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, FakeRole) and other.id == self.id
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
class FakeMember:
|
||||
def __init__(self, uid: int, name: str, roles, bot: bool = False):
|
||||
self.id = uid
|
||||
self.name = name
|
||||
self.display_name = name
|
||||
self.bot = bot
|
||||
self.roles = list(roles)
|
||||
|
||||
async def add_roles(self, *roles, reason=None):
|
||||
self.roles.extend(roles)
|
||||
|
||||
async def remove_roles(self, *roles, reason=None):
|
||||
self.roles = [r for r in self.roles if r not in roles]
|
||||
|
||||
|
||||
class FakeGuild:
|
||||
def __init__(self, roles, members=None):
|
||||
self.roles = list(roles)
|
||||
self.members = list(members or [])
|
||||
self._next = 9000
|
||||
self.created: list[str] = []
|
||||
|
||||
async def create_role(self, name, reason=None):
|
||||
self._next += 1
|
||||
role = FakeRole(self._next, name)
|
||||
self.roles.append(role)
|
||||
self.created.append(name)
|
||||
return role
|
||||
|
||||
|
||||
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
keeper = FakeRole(2, "Member") # not a team role - must be left alone
|
||||
member = FakeMember(1, "tft", roles=[old_team, keeper])
|
||||
guild = FakeGuild([old_team, keeper])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username",
|
||||
lambda n: "GENESIS" if n.lower() == "tft" else None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.created == "GENESIS" # auto-created the missing role
|
||||
assert "GENESIS" in guild.created
|
||||
assert result.added == "GENESIS"
|
||||
assert result.removed == ["OldTeam"] # left their previous team
|
||||
role_names = {r.name for r in member.roles}
|
||||
assert "GENESIS" in role_names
|
||||
assert "OldTeam" not in role_names
|
||||
assert "Member" in role_names # unrelated role untouched
|
||||
|
||||
|
||||
def test_sync_uses_existing_team_role(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
member = FakeMember(1, "kapa", roles=[])
|
||||
guild = FakeGuild([genesis])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert guild.created == [] # did NOT create a duplicate
|
||||
assert result.created is None
|
||||
assert result.added == "GENESIS"
|
||||
assert genesis in member.roles
|
||||
|
||||
|
||||
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
||||
old_team = FakeRole(1, "OldTeam")
|
||||
member = FakeMember(1, "ghost", roles=[old_team])
|
||||
guild = FakeGuild([old_team])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == ["OldTeam"]
|
||||
assert result.added is None
|
||||
assert old_team not in member.roles
|
||||
|
||||
|
||||
def test_sync_no_team_sheet_is_noop(monkeypatch):
|
||||
keeper = FakeRole(2, "Member")
|
||||
member = FakeMember(1, "someone", roles=[keeper])
|
||||
guild = FakeGuild([keeper])
|
||||
|
||||
# Empty caches = feature switched off: no removals even of a stale team role.
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.removed == []
|
||||
assert guild.created == []
|
||||
assert keeper in member.roles
|
||||
|
||||
|
||||
def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS
|
||||
m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged
|
||||
bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped
|
||||
guild = FakeGuild([genesis], members=[m1, m2, bot_member])
|
||||
|
||||
teams = {"kapa": "GENESIS"}
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower()))
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
|
||||
summary = run(member_sync.sync_all_team_roles(guild))
|
||||
|
||||
assert summary.scanned == 2 # bot excluded
|
||||
assert summary.assigned == 1
|
||||
assert summary.removed == 0
|
||||
assert summary.changes == ["kapa: +GENESIS"]
|
||||
Reference in New Issue
Block a user