forked from sass/tipibot
Compare commits
22 Commits
968356f925
...
feat/parti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb08bc56d7 | ||
|
|
a280ba05cc | ||
|
|
3c8927184b | ||
| b813ed5f81 | |||
|
|
c19e67b5ab | ||
| b20bca9244 | |||
|
|
d48a436e26 | ||
|
|
deab0f7a55 | ||
|
|
a4645e19c5 | ||
|
|
ce1ed28904 | ||
|
|
52002c37fc | ||
| 567a82b9f2 | |||
| f3c69738ef | |||
|
|
a2e1601d0e | ||
|
|
b9b4c7c4c7 | ||
|
|
254992c642 | ||
|
|
191726721c | ||
|
|
0ea5580e15 | ||
|
|
738144ccfa | ||
|
|
cec5ac01a0 | ||
|
|
3b35f82d80 | ||
| 5e303fe36f |
23
.env.example
23
.env.example
@@ -9,6 +9,29 @@ 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; a FALLBACK source for /teamsync + hourly
|
||||
# team-role sync on the economy/community bot. Leave unset to disable it.
|
||||
TEAM_SHEET_ID=
|
||||
|
||||
# Fienta ticketing - PRIMARY source for team-role sync. The registration collects
|
||||
# each competitor's Discord username + team name per ticket, so matching is by
|
||||
# real Discord handle (not game nickname). Get an API token from the Fienta admin
|
||||
# (organizer settings) and the event's numeric ID from its dashboard URL. Leave
|
||||
# unset to use only the sheet.
|
||||
FIENTA_API_TOKEN=
|
||||
FIENTA_EVENT_ID=
|
||||
|
||||
# Where each game's team roles get positioned in the role list. The key suffix
|
||||
# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2
|
||||
# Registration Log") - every underscore-separated part must appear in it, so
|
||||
# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any
|
||||
# year. The value is the Discord role ID to place those teams under (matching by
|
||||
# ID means renaming the divider role never breaks positioning).
|
||||
# Optional: sections that match nothing still get their roles, just unpositioned.
|
||||
TEAM_DIVIDER_CS2_2026=1498736834656604251
|
||||
TEAM_DIVIDER_LOL_2026=1498736949706490017
|
||||
|
||||
# Path to Google service account credentials JSON
|
||||
GOOGLE_CREDS_PATH=credentials.json
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,3 +10,6 @@ pocketbase
|
||||
pb_data/
|
||||
pb_migrations/
|
||||
logs/
|
||||
fientalog
|
||||
fientatickets
|
||||
fientaorders
|
||||
|
||||
62
README.md
62
README.md
@@ -10,8 +10,9 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go
|
||||
2. [Member Management](#member-management)
|
||||
3. [Admin Commands](#admin-commands)
|
||||
4. [Birthday System](#birthday-system)
|
||||
5. [TipiCOIN Economy](#tipicoin-economy)
|
||||
6. [Project Structure](#project-structure)
|
||||
5. [Tournament Team Roles](#tournament-team-roles)
|
||||
6. [TipiCOIN Economy](#tipicoin-economy)
|
||||
7. [Project Structure](#project-structure)
|
||||
|
||||
---
|
||||
|
||||
@@ -107,6 +108,8 @@ cp .env.example .env
|
||||
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
|
||||
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
||||
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
||||
| `TEAM_SHEET_ID` | Tournament registration spreadsheet (separate from the member sheet). Unset = team sync off |
|
||||
| `TEAM_DIVIDER_<GAME>_<YEAR>` | Divider role that a sheet section's teams get positioned under - see [Tournament team roles](#tournament-team-roles) |
|
||||
|
||||
### 6. Install & Run
|
||||
|
||||
@@ -168,6 +171,7 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark
|
||||
|---|---|---|
|
||||
| `/check` | Manage Roles | Refreshes sheet data, backfills missing User IDs, syncs nicknames + roles for every member, reports stats |
|
||||
| `/member @user` | Manage Roles | Shows a member's full sheet data + calculated age |
|
||||
| `/teamsync` | Bot admin | Reloads the tournament registration sheet, grants/removes team roles, and repositions them under their dividers. See [Tournament Team Roles](#tournament-team-roles) |
|
||||
| `/sync` | Manage Guild | Re-registers slash commands with Discord |
|
||||
| `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up |
|
||||
| `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting |
|
||||
@@ -220,9 +224,49 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi
|
||||
|
||||
---
|
||||
|
||||
## Tournament Team Roles
|
||||
|
||||
Runs on the **economy** profile only, driven by the separate `TEAM_SHEET_ID` spreadsheet. It is independent of the member roster - players are matched straight from the registration sheet by Discord username, and never need a row in the member sheet.
|
||||
|
||||
Re-runs automatically **every hour**, and on demand via `/teamsync` (admin).
|
||||
|
||||
### What it does
|
||||
|
||||
- Grants each registered player the role named after their team, creating that role if it doesn't exist yet
|
||||
- Removes team roles they no longer hold (switched teams, dropped out)
|
||||
- Positions every team role directly beneath its game's divider role
|
||||
- **Only role names present in the sheet are ever added, removed, or moved** - no unrelated role is at risk
|
||||
|
||||
### Divider placement
|
||||
|
||||
The registration sheet stacks several game sections per tab, each under a merged title row:
|
||||
|
||||
```
|
||||
[merged] TipiLAN 2026 CS2 Registration Log
|
||||
No | Team Name | Lineup (nickname, citizenship) | ...
|
||||
1 | GENESIS | kapa (EST), neaQ (EST) | ...
|
||||
```
|
||||
|
||||
Set one `TEAM_DIVIDER_*` variable per section to say where those teams belong:
|
||||
|
||||
```bash
|
||||
TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======"
|
||||
TEAM_DIVIDER_LOL_2026="===== LEAGUE OF LEGENDS 2026 ====="
|
||||
```
|
||||
|
||||
- **The key suffix** is matched against the section's title row. Every underscore-separated part must appear in it as a whole word, so `CS2_2026` matches only the 2026 CS2 block, while a plain `CS2` would match that game in any year. When several keys match, the most specific one (most parts) wins.
|
||||
- **The value** is the *exact* Discord role name, decoration included. Copy it from Server Settings → Roles.
|
||||
- Sections matching no key still get their team roles - they just aren't repositioned.
|
||||
|
||||
Teams are ordered alphabetically downwards under their divider. Every sync re-checks placement, so roles that predate this feature get pulled into place on the next run.
|
||||
|
||||
> The bot can only move roles **below its own** role. Drag the TipiBOT role above your dividers, or placement is skipped and reported as an error.
|
||||
|
||||
---
|
||||
|
||||
## 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 +501,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 +527,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)
|
||||
|
||||
56
bot.py
56
bot.py
@@ -21,9 +21,9 @@ import psutil
|
||||
|
||||
import config
|
||||
import strings as S
|
||||
from core import economy, pb_client, sheets
|
||||
from core import economy, fienta, 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,47 @@ async def before_birthday_daily():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
@tasks.loop(hours=1)
|
||||
async def team_sync_hourly():
|
||||
"""Reload the tournament registration (Fienta + 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 neither Fienta nor the team sheet is set.
|
||||
"""
|
||||
if IS_DEV_PROFILE or not (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN):
|
||||
return
|
||||
try:
|
||||
rosters = await sheets.refresh_teams()
|
||||
fienta_teams = await fienta.refresh_teams()
|
||||
except Exception as e:
|
||||
log.error("team_sync_hourly: failed to load team data: %s", e)
|
||||
return
|
||||
if not rosters and not fienta_teams:
|
||||
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 or summary.positioned
|
||||
or summary.divider_assigned or summary.divider_removed or summary.errors):
|
||||
log.info(
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, "
|
||||
"divider_assigned=%d, divider_removed=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created),
|
||||
summary.positioned, summary.divider_assigned, summary.divider_removed,
|
||||
len(summary.errors),
|
||||
)
|
||||
for err in summary.errors:
|
||||
log.warning("team_sync_hourly: %s", err)
|
||||
|
||||
|
||||
@team_sync_hourly.before_loop
|
||||
async def before_team_sync_hourly():
|
||||
await bot.wait_until_ready()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rotating rich presence
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,6 +475,12 @@ 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 or config.FIENTA_API_TOKEN)
|
||||
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 +538,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,
|
||||
|
||||
@@ -38,15 +38,21 @@ def register_economy_support_commands(
|
||||
)
|
||||
return
|
||||
|
||||
# Reserve the amount synchronously (no await between the range check
|
||||
# and the decrement) so two concurrent modal submits can't both fund
|
||||
# the same request. discord.py runs each submit as its own task and
|
||||
# do_give is not idempotent, so without this reservation a funder
|
||||
# could double-submit and transfer `amount` more than once.
|
||||
self._view.remaining -= amount
|
||||
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
|
||||
if not res["ok"]:
|
||||
self._view.remaining += amount # roll back the reservation
|
||||
data = await economy.get_user(interaction.user.id)
|
||||
await interaction.response.send_message(
|
||||
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self._view.remaining -= amount
|
||||
funded_line = S.REQUEST_UI["funded_line"].format(
|
||||
name=interaction.user.display_name,
|
||||
amount=coin(amount),
|
||||
@@ -84,6 +90,9 @@ def register_economy_support_commands(
|
||||
self.add_item(self.fund_btn)
|
||||
|
||||
async def _fund(self, interaction: discord.Interaction):
|
||||
if self.remaining <= 0 or self.is_finished():
|
||||
await interaction.response.send_message(S.ERR["request_closed"], ephemeral=True)
|
||||
return
|
||||
if interaction.user.id == self.requester.id:
|
||||
await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True)
|
||||
return
|
||||
@@ -154,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
|
||||
|
||||
98
commands/economy_team_commands.py
Normal file
98
commands/economy_team_commands.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""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 fienta, 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()
|
||||
fienta_teams = await fienta.refresh_teams()
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
||||
)
|
||||
return
|
||||
if not rosters and not fienta_teams:
|
||||
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, "
|
||||
"positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d",
|
||||
summary.scanned,
|
||||
summary.assigned,
|
||||
summary.removed,
|
||||
len(summary.created),
|
||||
summary.positioned,
|
||||
summary.divider_assigned,
|
||||
summary.divider_removed,
|
||||
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.positioned:
|
||||
lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned))
|
||||
if summary.divider_assigned:
|
||||
lines.append(S.TEAMSYNC_UI["divider_assigned"].format(count=summary.divider_assigned))
|
||||
if summary.divider_removed:
|
||||
lines.append(S.TEAMSYNC_UI["divider_removed"].format(count=summary.divider_removed))
|
||||
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
|
||||
47
config.py
47
config.py
@@ -23,8 +23,18 @@ 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")
|
||||
|
||||
# Fienta ticketing: the registration collects each competitor's Discord username
|
||||
# (+ sometimes Discord user ID) and team name per ticket, giving a reliable
|
||||
# Discord-identity -> team mapping the nickname-only sheet cannot. Primary source
|
||||
# for team-role sync; the sheet stays as a fallback. Unset -> Fienta is skipped.
|
||||
FIENTA_API_TOKEN = os.getenv("FIENTA_API_TOKEN", "")
|
||||
FIENTA_EVENT_ID = os.getenv("FIENTA_EVENT_ID", "")
|
||||
|
||||
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
||||
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
|
||||
@@ -66,6 +76,43 @@ def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
|
||||
|
||||
BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", ""))
|
||||
|
||||
_TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_"
|
||||
|
||||
|
||||
def _parse_team_dividers() -> dict[str, int]:
|
||||
"""Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role ID}.
|
||||
|
||||
The suffix says which sheet sections the divider covers, the value is the
|
||||
Discord role ID their teams get positioned under:
|
||||
|
||||
TEAM_DIVIDER_CS2_2026=1498736834656604251
|
||||
|
||||
Matching by ID (not name) means renaming the divider role in Discord never
|
||||
breaks positioning. Every underscore-separated part of the suffix must appear
|
||||
in the section's title row, so `CS2_2026` matches only "TipiLAN 2026 CS2
|
||||
Registration Log" while a plain `CS2` would match that section in any year.
|
||||
Defining a var is what switches positioning on for those sections; teams
|
||||
whose section matches nothing are still granted their role, just never moved.
|
||||
"""
|
||||
dividers: dict[str, int] = {}
|
||||
for key, value in os.environ.items():
|
||||
if not key.startswith(_TEAM_DIVIDER_PREFIX):
|
||||
continue
|
||||
suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower()
|
||||
raw = value.strip()
|
||||
if not suffix or not raw:
|
||||
continue
|
||||
try:
|
||||
dividers[suffix] = int(raw)
|
||||
except ValueError:
|
||||
raise SystemExit(
|
||||
f"{key}: expected a Discord role ID (integer), got {raw!r}"
|
||||
)
|
||||
return dividers
|
||||
|
||||
|
||||
TEAM_DIVIDERS: dict[str, int] = _parse_team_dividers()
|
||||
|
||||
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
||||
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
||||
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
190
core/fienta.py
Normal file
190
core/fienta.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Fienta ticketing integration - authoritative Discord -> team mapping.
|
||||
|
||||
The tournament registration on Fienta collects, per competitor ticket, the
|
||||
player's Discord username (and sometimes Discord user ID), their team name
|
||||
(order-level, echoed onto each attendee), and the ticket type (which names the
|
||||
game). That gives a reliable Discord-identity -> team mapping the nickname-only
|
||||
registration sheet cannot, so this is the PRIMARY source for team-role sync,
|
||||
with :mod:`core.sheets` kept as a fallback.
|
||||
|
||||
Enabled by ``FIENTA_API_TOKEN`` + ``FIENTA_EVENT_ID``; when either is unset all
|
||||
caches stay empty and every getter is a no-op, so team sync silently falls back
|
||||
to the sheet. Only real team members get roles - competitor, coach/manager and
|
||||
substitute tickets are included; visitors, supporters, LAN-access, early-bird
|
||||
and waiting-list tickets are excluded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://fienta.com/api/v1"
|
||||
_PAGE_SIZE = 1000 # Fienta's max per page; one page covers a whole tournament
|
||||
|
||||
# Ticket-type titles that represent an actual team member who should get a role.
|
||||
_INCLUDED_KEYWORDS = ("competitor", "coach", "manager", "substitute")
|
||||
# ...unless the title also matches one of these (visitors etc. are never players).
|
||||
_EXCLUDED_KEYWORDS = (
|
||||
"visitor", "supporter", "lan area", "early bird", "waiting list", "waitlist",
|
||||
)
|
||||
|
||||
# Attendee custom-field keys. Fienta appends the field id to the machine name;
|
||||
# these come from GET /events/{id}/custom-fields for event 176532.
|
||||
_F_DISCORD_USERNAME = "discord_username_134871"
|
||||
_F_DISCORD_USERID = "discord_user_id_135840"
|
||||
_F_TEAM_NAME = "team_name_134821"
|
||||
|
||||
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
|
||||
("CS2", ("counter-strike", "counter strike", "cs2", "csgo", "cs:go")),
|
||||
("LoL", ("league of legends", "league", "lol")),
|
||||
]
|
||||
|
||||
# Caches, rebuilt by refresh_teams()/parse_tickets().
|
||||
_by_username: dict[str, str] = {} # discord username (lower) -> team name
|
||||
_by_userid: dict[str, str] = {} # discord user id (str) -> team name
|
||||
_team_game: dict[str, str] = {} # team name -> "CS2" / "LoL"
|
||||
_team_names: set[str] = set()
|
||||
|
||||
|
||||
def _norm_username(name: str) -> str:
|
||||
"""Normalise a Discord username for matching: lowercased, no leading @."""
|
||||
return name.strip().lower().lstrip("@")
|
||||
|
||||
|
||||
def _detect_game(ticket_type_title: str) -> str | None:
|
||||
"""Return the game code for a ticket-type title, or None if unrecognised."""
|
||||
title = ticket_type_title.lower()
|
||||
for game, keywords in _GAME_KEYWORDS:
|
||||
if any(k in title for k in keywords):
|
||||
return game
|
||||
return None
|
||||
|
||||
|
||||
def _is_included(ticket_type_title: str) -> bool:
|
||||
"""True when this ticket type is an actual team member (not a visitor etc.)."""
|
||||
title = ticket_type_title.lower()
|
||||
if any(k in title for k in _EXCLUDED_KEYWORDS):
|
||||
return False
|
||||
return any(k in title for k in _INCLUDED_KEYWORDS)
|
||||
|
||||
|
||||
def _game_divider_ids() -> dict[str, int]:
|
||||
"""Map game code -> divider role id, derived from ``config.TEAM_DIVIDERS``.
|
||||
|
||||
Reuses the same ``TEAM_DIVIDER_<SUFFIX>`` role IDs the sheet path uses: a
|
||||
suffix like ``cs2_2026`` contributes its id to game ``CS2``.
|
||||
"""
|
||||
out: dict[str, int] = {}
|
||||
for suffix, rid in config.TEAM_DIVIDERS.items():
|
||||
parts = suffix.split("_")
|
||||
if any(p in ("cs2", "cs", "csgo") for p in parts):
|
||||
out.setdefault("CS2", rid)
|
||||
if any(p in ("lol", "league") for p in parts):
|
||||
out.setdefault("LoL", rid)
|
||||
return out
|
||||
|
||||
|
||||
def parse_tickets(tickets: list[dict]) -> None:
|
||||
"""Rebuild the caches from a list of Fienta ticket objects.
|
||||
|
||||
Pure/synchronous so it can be unit-tested without hitting the API.
|
||||
"""
|
||||
global _by_username, _by_userid, _team_game, _team_names
|
||||
by_username: dict[str, str] = {}
|
||||
by_userid: dict[str, str] = {}
|
||||
team_game: dict[str, str] = {}
|
||||
for ticket in tickets:
|
||||
rows = ticket.get("rows") or []
|
||||
if not rows:
|
||||
continue
|
||||
row = rows[0]
|
||||
title = (row.get("ticket_type") or {}).get("title", "")
|
||||
if not _is_included(title):
|
||||
continue
|
||||
attendee = row.get("attendee") or {}
|
||||
team = (attendee.get(_F_TEAM_NAME) or "").strip()
|
||||
if not team:
|
||||
continue
|
||||
game = _detect_game(title)
|
||||
# Keep the first non-None game seen for a team (all its tickets agree).
|
||||
team_game[team] = game or team_game.get(team)
|
||||
uname = _norm_username(attendee.get(_F_DISCORD_USERNAME) or "")
|
||||
uid = (attendee.get(_F_DISCORD_USERID) or "").strip()
|
||||
if uname:
|
||||
by_username[uname] = team
|
||||
if uid.isdigit():
|
||||
by_userid[uid] = team
|
||||
_by_username = by_username
|
||||
_by_userid = by_userid
|
||||
_team_game = team_game
|
||||
_team_names = set(team_game)
|
||||
|
||||
|
||||
async def refresh_teams() -> set[str]:
|
||||
"""Fetch competitor tickets from Fienta and rebuild the caches.
|
||||
|
||||
No-op returning an empty set when ``FIENTA_API_TOKEN`` / ``FIENTA_EVENT_ID``
|
||||
are unset, so the caller transparently falls back to the sheet.
|
||||
"""
|
||||
if not config.FIENTA_API_TOKEN or not config.FIENTA_EVENT_ID:
|
||||
parse_tickets([])
|
||||
return set()
|
||||
|
||||
url = f"{_API_BASE}/events/{config.FIENTA_EVENT_ID}/tickets"
|
||||
headers = {"Authorization": f"Bearer {config.FIENTA_API_TOKEN}"}
|
||||
tickets: list[dict] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
page = 1
|
||||
while True:
|
||||
params = {"attendees": "true", "per_page": str(_PAGE_SIZE), "page": str(page)}
|
||||
async with session.get(url, headers=headers, params=params) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
batch = data.get("tickets") or []
|
||||
tickets.extend(batch)
|
||||
if len(batch) < _PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
|
||||
parse_tickets(tickets)
|
||||
log.info(
|
||||
"Fienta: %d tickets -> %d teams, %d discord usernames, %d discord ids",
|
||||
len(tickets), len(_team_names), len(_by_username), len(_by_userid),
|
||||
)
|
||||
return set(_team_names)
|
||||
|
||||
|
||||
def get_team_for_username(username: str) -> str | None:
|
||||
"""Team the given Discord username is registered on, or None."""
|
||||
return _by_username.get(_norm_username(username))
|
||||
|
||||
|
||||
def get_team_for_userid(user_id: int) -> str | None:
|
||||
"""Team the given Discord user ID is registered on, or None (IDs are sparse)."""
|
||||
return _by_userid.get(str(user_id))
|
||||
|
||||
|
||||
def all_team_names() -> set[str]:
|
||||
"""Every team name seen in the included Fienta tickets."""
|
||||
return set(_team_names)
|
||||
|
||||
|
||||
def get_team_game(team: str) -> str | None:
|
||||
"""Game code ("CS2"/"LoL") for a team, or None."""
|
||||
return _team_game.get(team)
|
||||
|
||||
|
||||
def get_team_dividers() -> dict[str, int]:
|
||||
"""{team name -> divider role id}, via each team's game and config dividers."""
|
||||
game_div = _game_divider_ids()
|
||||
return {
|
||||
team: game_div[game]
|
||||
for team, game in _team_game.items()
|
||||
if game and game in game_div
|
||||
}
|
||||
@@ -11,9 +11,32 @@ from zoneinfo import ZoneInfo
|
||||
import discord
|
||||
|
||||
import config
|
||||
from . import sheets
|
||||
from . import fienta, sheets
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_team(member: discord.Member) -> str | None:
|
||||
"""Team a member is registered on: Fienta first (by ID, then username),
|
||||
then the sheet by username. Fienta is authoritative; the sheet is fallback."""
|
||||
return (
|
||||
fienta.get_team_for_userid(member.id)
|
||||
or fienta.get_team_for_username(member.name)
|
||||
or sheets.get_team_for_username(member.name)
|
||||
)
|
||||
|
||||
|
||||
def all_managed_team_names() -> set[str]:
|
||||
"""Union of every team name from Fienta and the sheet - the only role names
|
||||
team sync ever adds or removes."""
|
||||
return fienta.all_team_names() | sheets.all_team_names()
|
||||
|
||||
|
||||
def team_dividers() -> dict[str, int]:
|
||||
"""{team -> divider role id} merged from both sources; Fienta wins on overlap."""
|
||||
merged = dict(sheets.get_team_dividers())
|
||||
merged.update(fienta.get_team_dividers())
|
||||
return merged
|
||||
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||
_TZ = ZoneInfo("Europe/Tallinn")
|
||||
|
||||
@@ -48,7 +71,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 +79,36 @@ 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
|
||||
divider_added: str | None = None # game divider role granted as participant tag
|
||||
divider_removed: list[str] = field(default_factory=list) # stale divider roles taken away
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.added or self.removed or self.divider_added or self.divider_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)
|
||||
positioned: int = 0 # team roles moved under a divider
|
||||
divider_assigned: int = 0 # members given their game divider role
|
||||
divider_removed: int = 0 # stale game divider roles taken away
|
||||
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 +275,272 @@ 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);
|
||||
* grants their game's divider role as a participant tag (and strips any
|
||||
other configured divider role they still carry, i.e. switched game).
|
||||
|
||||
Only team role NAMES present in the team sheet and the configured divider
|
||||
role IDs (``config.TEAM_DIVIDERS``) 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 = resolve_team(member)
|
||||
all_teams = all_managed_team_names()
|
||||
if not all_teams:
|
||||
return result # feature switched off (no Fienta token and no team sheet)
|
||||
|
||||
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}")
|
||||
|
||||
# --- Participant divider role (the game's divider role doubles as a tag) ---
|
||||
# Grant the divider role for the member's team's game, and strip any other
|
||||
# configured divider role (switched game / dropped out). Matched by ID, so
|
||||
# only the roles named in config.TEAM_DIVIDERS are ever touched.
|
||||
divider_ids = set(config.TEAM_DIVIDERS.values())
|
||||
want_divider_id = team_dividers().get(team_name) if team_name else None
|
||||
want_divider = guild.get_role(want_divider_id) if want_divider_id else None
|
||||
|
||||
if want_divider is not None and want_divider not in member.roles:
|
||||
try:
|
||||
await member.add_roles(want_divider, reason="Team sync: mänguosaleja")
|
||||
result.divider_added = want_divider.name
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to add divider role for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Eraldajarolli viga kasutajale {member}: {e}")
|
||||
|
||||
stale_dividers = [
|
||||
r for r in member.roles if r.id in divider_ids and r.id != want_divider_id
|
||||
]
|
||||
if stale_dividers:
|
||||
try:
|
||||
await member.remove_roles(*stale_dividers, reason="Team sync: mäng vahetus")
|
||||
result.divider_removed = [r.name for r in stale_dividers]
|
||||
except discord.Forbidden:
|
||||
log.debug("No permission to remove divider roles for %s, skipping", member)
|
||||
except discord.HTTPException as e:
|
||||
result.errors.append(f"Eraldajarolli eemaldamise viga kasutajale {member}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def plan_team_positions(
|
||||
ordered_names: list[str],
|
||||
managed: set[str],
|
||||
placements: dict[str, list[str]],
|
||||
) -> dict[str, int]:
|
||||
"""Work out the new position of every team role that needs to move.
|
||||
|
||||
``ordered_names`` is every role name ascending by Discord position, so index
|
||||
0 is the bottom of the role list (@everyone) and the last entry is the top.
|
||||
``placements`` maps a divider role name to the team roles that belong under
|
||||
it. Roles in ``managed`` are never moved (integration-managed roles, and
|
||||
anything at or above the bot's own role, cannot be repositioned).
|
||||
|
||||
Teams are pulled out of the list and re-inserted immediately below their
|
||||
divider, sorted so they read alphabetically top-to-bottom in the Discord UI.
|
||||
Everything else keeps its relative order; a role only appears in the result
|
||||
when its position actually changed. Roles are matched by name, mirroring the
|
||||
rest of the team sync - with duplicate role names the lowest one wins.
|
||||
"""
|
||||
present = set(ordered_names)
|
||||
placeable: dict[str, list[str]] = {}
|
||||
for divider, teams in placements.items():
|
||||
if divider not in present or divider in managed:
|
||||
continue
|
||||
# Descending here because the list is bottom-up: reversing it renders
|
||||
# alphabetically downwards from the divider.
|
||||
block = sorted({t for t in teams if t in present and t not in managed}, reverse=True)
|
||||
if block:
|
||||
placeable[divider] = block
|
||||
if not placeable:
|
||||
return {}
|
||||
|
||||
movable = {t for block in placeable.values() for t in block}
|
||||
remaining = [n for n in ordered_names if n not in movable]
|
||||
for divider, block in placeable.items():
|
||||
idx = remaining.index(divider)
|
||||
remaining[idx:idx] = block
|
||||
|
||||
old_pos = {name: i for i, name in enumerate(ordered_names)}
|
||||
return {
|
||||
name: i
|
||||
for i, name in enumerate(remaining)
|
||||
if name not in managed and old_pos.get(name) != i
|
||||
}
|
||||
|
||||
|
||||
async def apply_team_role_positions(
|
||||
guild: discord.Guild,
|
||||
log: logging.Logger = log,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Move every team role directly beneath its configured divider role.
|
||||
|
||||
Driven by the ``TEAM_DIVIDER_*`` config: teams whose sheet section matched
|
||||
one are placed under that divider role, the rest are left exactly where they
|
||||
are. Dividers are configured by role ID, so a rename never breaks placement;
|
||||
the ID is resolved to the role's current name here, and the ordering maths
|
||||
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
|
||||
``(0, [])``.
|
||||
"""
|
||||
team_divider_ids = team_dividers() # {team: divider role ID}, both sources
|
||||
if not team_divider_ids:
|
||||
return 0, [] # no dividers configured, or nothing matched
|
||||
|
||||
errors: list[str] = []
|
||||
# Resolve each configured divider ID to its role once, then key placements by
|
||||
# that role's current name for the name-based positioning maths below.
|
||||
placements: dict[str, list[str]] = {}
|
||||
resolved: dict[int, discord.Role | None] = {}
|
||||
for team, divider_id in team_divider_ids.items():
|
||||
if divider_id not in resolved:
|
||||
resolved[divider_id] = guild.get_role(divider_id)
|
||||
if resolved[divider_id] is None:
|
||||
errors.append(f"Eraldajarolli ID {divider_id} ei leitud serverist")
|
||||
divider = resolved[divider_id]
|
||||
if divider is not None:
|
||||
placements.setdefault(divider.name, []).append(team)
|
||||
if not placements:
|
||||
return 0, errors
|
||||
|
||||
by_name: dict[str, discord.Role] = {}
|
||||
for role in sorted(guild.roles, key=lambda r: r.position):
|
||||
by_name.setdefault(role.name, role)
|
||||
|
||||
# The bot can only reorder roles strictly below its own highest role.
|
||||
bot_top = max((r.position for r in guild.me.roles), default=0)
|
||||
for divider in list(placements):
|
||||
role = by_name.get(divider)
|
||||
if role is None:
|
||||
errors.append(f"Eraldajarolli '{divider}' ei leitud serverist")
|
||||
placements.pop(divider)
|
||||
elif role.position >= bot_top:
|
||||
errors.append(f"Eraldaja '{divider}' on boti rollist kõrgemal - ei saa liigutada")
|
||||
placements.pop(divider)
|
||||
if not placements:
|
||||
return 0, errors
|
||||
|
||||
ordered = sorted(guild.roles, key=lambda r: r.position)
|
||||
ordered_names = [r.name for r in ordered]
|
||||
managed = {r.name for r in ordered if r.managed or r.position >= bot_top}
|
||||
plan = plan_team_positions(ordered_names, managed, placements)
|
||||
if not plan:
|
||||
return 0, errors # already in the right place
|
||||
|
||||
positions = {by_name[n]: p for n, p in plan.items() if n in by_name}
|
||||
try:
|
||||
await guild.edit_role_positions(positions=positions)
|
||||
except discord.Forbidden:
|
||||
errors.append("Tiimirollide järjestamiseks puudub õigus")
|
||||
return 0, errors
|
||||
except discord.HTTPException as e:
|
||||
errors.append(f"Tiimirollide järjestamine ebaõnnestus: {e}")
|
||||
# 50013 here despite Manage Roles usually means a role in the batch sits
|
||||
# at/above the bot's top role. Log the batch vs bot_top to pinpoint it.
|
||||
log.warning(
|
||||
"edit_role_positions failed (%s); bot_top=%d; batch=%s",
|
||||
e, bot_top,
|
||||
sorted(
|
||||
((r.name, r.position, target) for r, target in positions.items()),
|
||||
key=lambda x: -x[1],
|
||||
),
|
||||
)
|
||||
return 0, errors
|
||||
log.info("Positioned %d team role(s) under their dividers", len(positions))
|
||||
return len(positions), errors
|
||||
|
||||
|
||||
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.divider_added:
|
||||
summary.divider_assigned += 1
|
||||
if res.divider_removed:
|
||||
summary.divider_removed += len(res.divider_removed)
|
||||
if res.changed:
|
||||
bits: list[str] = []
|
||||
if res.added:
|
||||
bits.append(f"+{res.added}")
|
||||
if res.removed:
|
||||
bits.append("-" + ", -".join(res.removed))
|
||||
if res.divider_added:
|
||||
bits.append(f"+[{res.divider_added}]")
|
||||
if res.divider_removed:
|
||||
bits.append("-[" + "], -[".join(res.divider_removed) + "]")
|
||||
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
||||
|
||||
# Placement runs after the grant/remove pass so roles created this run are
|
||||
# positioned in the same sweep rather than waiting for the next one.
|
||||
summary.positioned, position_errors = await apply_team_role_positions(guild, log)
|
||||
summary.errors.extend(position_errors)
|
||||
return summary
|
||||
|
||||
|
||||
async def announce_birthday(
|
||||
member: discord.Member,
|
||||
bot: discord.Client,
|
||||
|
||||
271
core/sheets.py
271
core/sheets.py
@@ -7,12 +7,17 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import gspread
|
||||
from google.oauth2.service_account import Credentials
|
||||
|
||||
import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Scopes needed: read + write to Sheets
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
@@ -69,12 +74,25 @@ def _get_worksheet() -> gspread.Worksheet:
|
||||
|
||||
|
||||
def _ensure_headers(ws: gspread.Worksheet) -> None:
|
||||
"""If the sheet is empty or missing headers, write them (headers are in row 1)."""
|
||||
"""Verify the header row matches what we expect.
|
||||
|
||||
The production sheet is owner-managed and its header row (row 1) is a
|
||||
protected range, so the bot must NOT write to it — attempting to do so
|
||||
raises `APIError [400]: You are trying to edit a protected cell or object`
|
||||
and aborts the whole refresh. We only log a mismatch so it can be fixed
|
||||
by hand; column lookups still work as long as the headers we rely on exist.
|
||||
"""
|
||||
existing = ws.row_values(1)
|
||||
if existing != EXPECTED_HEADERS:
|
||||
for col_idx, header in enumerate(EXPECTED_HEADERS, start=1):
|
||||
if col_idx > len(existing) or existing[col_idx - 1] != header:
|
||||
ws.update_cell(1, col_idx, header)
|
||||
missing = [h for h in EXPECTED_HEADERS if h not in existing]
|
||||
log.warning(
|
||||
"Sheet header row does not match EXPECTED_HEADERS "
|
||||
"(missing/renamed: %s). Expected %s, found %s. "
|
||||
"Not writing to the (protected) header row; fix it manually if needed.",
|
||||
missing or "none — order/whitespace differs",
|
||||
EXPECTED_HEADERS,
|
||||
existing,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_sync() -> list[dict]:
|
||||
@@ -234,3 +252,248 @@ 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
|
||||
_team_divider: dict[str, int] = {} # team name -> divider role ID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TeamSection:
|
||||
"""One game's block of teams within a tab, plus the title row above it.
|
||||
|
||||
The title ("TipiLAN 2026 CS2 Registration Log") is the only thing that says
|
||||
which game and year a block belongs to - the header and team rows below it
|
||||
carry neither - so it is what :func:`resolve_divider` matches against.
|
||||
"""
|
||||
title: str = ""
|
||||
rosters: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
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 _merged_title(row: list) -> str | None:
|
||||
"""Return a row's lone non-empty cell - a merged section title - else None.
|
||||
|
||||
A fully blank row returns None rather than "", so blank separators between
|
||||
sections do not wipe the title we are holding for the next header row.
|
||||
"""
|
||||
values = [str(c).strip() for c in row if str(c).strip()]
|
||||
return values[0] if len(values) == 1 else None
|
||||
|
||||
|
||||
def parse_team_sections(rows: list[list]) -> list[TeamSection]:
|
||||
"""Extract each game's block of teams from a tab's raw rows, with its title.
|
||||
|
||||
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, and
|
||||
tags each with the most recent merged title row seen above it.
|
||||
"""
|
||||
sections: list[TeamSection] = []
|
||||
title = ""
|
||||
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:
|
||||
if (text := _merged_title(rows[i])) is not None:
|
||||
# Accumulate every single-cell row above the header, not just the
|
||||
# last one: sheets stack the "TipiLAN 2026 CS2" title above notice
|
||||
# rows ("If a team withdraws..."), and the later notices must not
|
||||
# clobber the title whose keywords resolve_divider needs.
|
||||
title = f"{title} {text}".strip() if title else text
|
||||
i += 1
|
||||
continue
|
||||
rosters: dict[str, list[str]] = {}
|
||||
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
|
||||
if rosters:
|
||||
sections.append(TeamSection(title=title, rosters=rosters))
|
||||
title = "" # consumed - do not leak it onto the next section
|
||||
return sections
|
||||
|
||||
|
||||
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||
"""Flatten every section in a tab into {team_name: [nickname, ...]}."""
|
||||
rosters: dict[str, list[str]] = {}
|
||||
for section in parse_team_sections(rows):
|
||||
for team, players in section.rosters.items():
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
return rosters
|
||||
|
||||
|
||||
def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None:
|
||||
"""Return the divider role ID configured for a section title, if any.
|
||||
|
||||
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated
|
||||
part of its suffix appears as a whole word in the title, so ``CS2`` matches
|
||||
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one.
|
||||
The most specific match (most parts) wins, which lets a year-scoped entry
|
||||
override a general one for the same game.
|
||||
"""
|
||||
if dividers is None:
|
||||
dividers = config.TEAM_DIVIDERS
|
||||
haystack = title.lower()
|
||||
best_id: int | None = None
|
||||
best_parts = 0
|
||||
for suffix, role_id in dividers.items():
|
||||
parts = [p for p in suffix.split("_") if p]
|
||||
if not parts or len(parts) <= best_parts:
|
||||
continue
|
||||
if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts):
|
||||
best_id, best_parts = role_id, len(parts)
|
||||
return best_id
|
||||
|
||||
|
||||
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, _team_divider
|
||||
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]] = {}
|
||||
dividers: dict[str, int] = {}
|
||||
for ws in spreadsheet.worksheets():
|
||||
for section in parse_team_sections(ws.get_all_values()):
|
||||
# Match on the tab name too ("CS2"/"LoL"): it names the game reliably
|
||||
# even when the game/year title row is shadowed by notice rows, while
|
||||
# the section title still supplies the year for year-scoped dividers.
|
||||
divider = resolve_divider(f"{ws.title} {section.title}")
|
||||
for team, players in section.rosters.items():
|
||||
rosters.setdefault(team, []).extend(players)
|
||||
if divider:
|
||||
dividers[team] = divider
|
||||
|
||||
_team_roster = rosters
|
||||
_team_by_username = build_username_index(rosters)
|
||||
_team_names = set(rosters)
|
||||
_team_divider = dividers
|
||||
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
|
||||
|
||||
|
||||
def get_team_dividers() -> dict[str, int]:
|
||||
"""Current {team: divider role ID} cache.
|
||||
|
||||
Only teams whose section title matched a configured TEAM_DIVIDER_* entry
|
||||
appear here, so an empty dict means positioning is switched off.
|
||||
"""
|
||||
return dict(_team_divider)
|
||||
|
||||
@@ -9,17 +9,17 @@ 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 |
|
||||
| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement |
|
||||
|
||||
### `commands/` - one slash-command group per file
|
||||
|
||||
@@ -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,41 @@ 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).
|
||||
|
||||
---
|
||||
|
||||
## Tournament Team Roles
|
||||
|
||||
Economy profile only. Roster-independent: matches Discord usernames straight against `TEAM_SHEET_ID`, never the member sheet. Entry points are `/teamsync` (`commands/economy_team_commands.py`) and the hourly `team_sync_hourly` task in `bot.py`; both call `sheets.refresh_teams()` then `member_sync.sync_all_team_roles()`.
|
||||
|
||||
### Pipeline
|
||||
|
||||
| Step | Where | Notes |
|
||||
|---|---|---|
|
||||
| Split a tab into per-game blocks | `sheets.parse_team_sections` | Returns `TeamSection(title, rosters)`. The merged title row above each header is the **only** thing identifying game + year |
|
||||
| Flatten to `{team: [players]}` | `sheets.parse_team_rosters` | Thin wrapper over `parse_team_sections` |
|
||||
| Section title → divider role name | `sheets.resolve_divider` | Matches `config.TEAM_DIVIDERS`; most specific key wins |
|
||||
| Cache | `sheets._refresh_teams_sync` | Populates `_team_roster`, `_team_by_username`, `_team_names`, `_team_divider` |
|
||||
| Grant/remove roles per member | `member_sync.sync_team_role` | Only names in `all_team_names()` are ever touched |
|
||||
| Compute new role positions | `member_sync.plan_team_positions` | **Pure** - list of names ascending + managed set + placements → `{name: new position}`. Unit-tested without Discord |
|
||||
| Apply | `member_sync.apply_team_role_positions` | One `guild.edit_role_positions` call per sync |
|
||||
|
||||
### Adding a game to the divider config
|
||||
|
||||
1. `.env` - add `TEAM_DIVIDER_<GAME>_<YEAR>="<exact Discord role name>"`. No code change; `config._parse_team_dividers` discovers any var with that prefix at startup.
|
||||
2. Restart the bot (env is read once at import).
|
||||
|
||||
### Position maths gotchas
|
||||
|
||||
- Discord positions are **ascending from the bottom** (`@everyone` = 0), so "under the divider in the UI" means a *lower* number. `plan_team_positions` therefore inserts each block reverse-sorted.
|
||||
- The bot can only reorder roles strictly below its own highest role. Dividers at or above it are skipped with an error rather than attempted.
|
||||
- `managed` roles (integration/bot/booster) are never emitted in the plan.
|
||||
- Roles are matched **by name** throughout the feature; with duplicate names the lowest-positioned one wins.
|
||||
- Only roles whose position actually changed are submitted, so a settled guild costs zero API calls.
|
||||
|
||||
---
|
||||
|
||||
@@ -179,49 +210,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.
|
||||
|
||||
1415
strings.py
1415
strings.py
File diff suppressed because it is too large
Load Diff
192
strings/__init__.py
Normal file
192
strings/__init__.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""All user-facing text for TipiLAN Bot.
|
||||
|
||||
Strings live in domain submodules (common, commands, member, economy, admin,
|
||||
games, fishing) and are re-exported here, so ``import strings`` and
|
||||
``strings.NAME`` keep working unchanged. Edit the relevant submodule to change
|
||||
any message, description, or flavour text without touching any logic code."""
|
||||
|
||||
from .common import (
|
||||
MSG_BANNED,
|
||||
MSG_SPAM_JAIL,
|
||||
MSG_PONG,
|
||||
MSG_RESTART_DONE,
|
||||
MSG_RESTARTING,
|
||||
MSG_SHUTTING_DOWN,
|
||||
MSG_PAUSED,
|
||||
MSG_UNPAUSED,
|
||||
MSG_MAINTENANCE,
|
||||
MSG_SYNC_DONE,
|
||||
MSG_REMINDER,
|
||||
MSG_LEVELUP,
|
||||
MSG_LEVELUP_ROLE,
|
||||
REMINDER_OPTS,
|
||||
TITLE,
|
||||
ERR,
|
||||
CD_MSG,
|
||||
REMINDERS_UI,
|
||||
SEND_UI,
|
||||
PATCHNOTES_UI,
|
||||
STATUS_UI,
|
||||
)
|
||||
|
||||
from .commands import (
|
||||
CMD,
|
||||
OPT,
|
||||
HELP_CATEGORIES,
|
||||
HELP_UI,
|
||||
)
|
||||
|
||||
from .member import (
|
||||
MEMBER_UI,
|
||||
MEMBER_FIELDS,
|
||||
ECONOMYSETUP_UI,
|
||||
CHANNEL_UI,
|
||||
BIRTHDAY_UI,
|
||||
BIRTHDAY_MONTHS,
|
||||
CHECK_UI,
|
||||
TEAMSYNC_UI,
|
||||
)
|
||||
|
||||
from .economy import (
|
||||
WORK_JOBS,
|
||||
BEG_LINES,
|
||||
BEG_JAIL_LINES,
|
||||
CRIME_WIN,
|
||||
CRIME_LOSE,
|
||||
QUEST_UI,
|
||||
QUEST_DESCRIPTIONS,
|
||||
SHOP_UI,
|
||||
ITEM_DESCRIPTIONS,
|
||||
CONSUMABLES_UI,
|
||||
CONSUMABLE_DESCRIPTIONS,
|
||||
JAILED_UI,
|
||||
SHOP_BTN,
|
||||
DAILY_UI,
|
||||
STATS_UI,
|
||||
PROFILE_UI,
|
||||
BALANCE_UI,
|
||||
COOLDOWNS_UI,
|
||||
RANK_UI,
|
||||
WORK_UI,
|
||||
BEG_UI,
|
||||
CRIME_UI,
|
||||
ROB_UI,
|
||||
GIVE_UI,
|
||||
BUY_UI,
|
||||
LEADERBOARD_UI,
|
||||
REQUEST_UI,
|
||||
)
|
||||
|
||||
from .admin import (
|
||||
ADMINVIEW_UI,
|
||||
ADMIN,
|
||||
SEASON,
|
||||
PRESTIGE_SHOP_NAMES,
|
||||
PRESTIGE_SHOP_DESCRIPTIONS,
|
||||
PRESTIGE_UI,
|
||||
)
|
||||
|
||||
from .games import (
|
||||
SLOTS_TIERS,
|
||||
ROULETTE,
|
||||
HEIST_STORY,
|
||||
HEIST_UI,
|
||||
BJ,
|
||||
JAILBREAK_UI,
|
||||
SLOTS_UI,
|
||||
RPS_UI,
|
||||
RPS_CHOICES,
|
||||
BJ_UI,
|
||||
)
|
||||
|
||||
from .fishing import (
|
||||
FISH_NAMES,
|
||||
FISH_RARITY_NAMES,
|
||||
FISH_RARITY_EMOJI,
|
||||
FISH_JUNK_LINES,
|
||||
FISH_UI,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'MSG_BANNED',
|
||||
'MSG_SPAM_JAIL',
|
||||
'MSG_PONG',
|
||||
'MSG_RESTART_DONE',
|
||||
'MSG_RESTARTING',
|
||||
'MSG_SHUTTING_DOWN',
|
||||
'MSG_PAUSED',
|
||||
'MSG_UNPAUSED',
|
||||
'MSG_MAINTENANCE',
|
||||
'MSG_SYNC_DONE',
|
||||
'MSG_REMINDER',
|
||||
'MSG_LEVELUP',
|
||||
'MSG_LEVELUP_ROLE',
|
||||
'REMINDER_OPTS',
|
||||
'TITLE',
|
||||
'ERR',
|
||||
'CD_MSG',
|
||||
'REMINDERS_UI',
|
||||
'SEND_UI',
|
||||
'PATCHNOTES_UI',
|
||||
'STATUS_UI',
|
||||
'CMD',
|
||||
'OPT',
|
||||
'HELP_CATEGORIES',
|
||||
'HELP_UI',
|
||||
'MEMBER_UI',
|
||||
'MEMBER_FIELDS',
|
||||
'ECONOMYSETUP_UI',
|
||||
'CHANNEL_UI',
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
'WORK_JOBS',
|
||||
'BEG_LINES',
|
||||
'BEG_JAIL_LINES',
|
||||
'CRIME_WIN',
|
||||
'CRIME_LOSE',
|
||||
'QUEST_UI',
|
||||
'QUEST_DESCRIPTIONS',
|
||||
'SHOP_UI',
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
'STATS_UI',
|
||||
'PROFILE_UI',
|
||||
'BALANCE_UI',
|
||||
'COOLDOWNS_UI',
|
||||
'RANK_UI',
|
||||
'WORK_UI',
|
||||
'BEG_UI',
|
||||
'CRIME_UI',
|
||||
'ROB_UI',
|
||||
'GIVE_UI',
|
||||
'BUY_UI',
|
||||
'LEADERBOARD_UI',
|
||||
'REQUEST_UI',
|
||||
'ADMINVIEW_UI',
|
||||
'ADMIN',
|
||||
'SEASON',
|
||||
'PRESTIGE_SHOP_NAMES',
|
||||
'PRESTIGE_SHOP_DESCRIPTIONS',
|
||||
'PRESTIGE_UI',
|
||||
'SLOTS_TIERS',
|
||||
'ROULETTE',
|
||||
'HEIST_STORY',
|
||||
'HEIST_UI',
|
||||
'BJ',
|
||||
'JAILBREAK_UI',
|
||||
'SLOTS_UI',
|
||||
'RPS_UI',
|
||||
'RPS_CHOICES',
|
||||
'BJ_UI',
|
||||
'FISH_NAMES',
|
||||
'FISH_RARITY_NAMES',
|
||||
'FISH_RARITY_EMOJI',
|
||||
'FISH_JUNK_LINES',
|
||||
'FISH_UI',
|
||||
]
|
||||
125
strings/admin.py
Normal file
125
strings/admin.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Economy admin responses, season reset, and prestige system.
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
from core.emoji import EMOJI as E
|
||||
|
||||
__all__ = [
|
||||
'ADMINVIEW_UI',
|
||||
'ADMIN',
|
||||
'SEASON',
|
||||
'PRESTIGE_SHOP_NAMES',
|
||||
'PRESTIGE_SHOP_DESCRIPTIONS',
|
||||
'PRESTIGE_UI',
|
||||
]
|
||||
|
||||
ADMINVIEW_UI: dict[str, str] = {
|
||||
"title": "🔍 {name} - majandusandmed",
|
||||
"banned_yes": "🚫 JAH",
|
||||
"banned_no": "✅ Ei",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_exp": "📊 EXP / Tase",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_banned": "🚫 Keelatud",
|
||||
"f_jailed": "🚔 Vangis kuni",
|
||||
"f_prestige": "🔥 Prestiiž",
|
||||
"f_items": "🎒 Esemed",
|
||||
"f_uses": "🔢 Kasutused",
|
||||
"f_fish": "🎣 Kala",
|
||||
"f_last_daily": "⏱️ Viimati daily",
|
||||
"f_last_work": "⏱️ Viimati work",
|
||||
"f_last_crime": "⏱️ Viimati crime",
|
||||
"f_last_fish": "⏱️ Viimati fish",
|
||||
"footer": "ID: {uid}",
|
||||
"exp_val": "{exp} EXP (Tase {level})",
|
||||
"prestige_val": "Prestiiž {level} · {pp} PP",
|
||||
"fish_val": "{caught} püütud · {inv} inventaris",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin command responses and DMs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ADMIN: dict[str, str] = {
|
||||
"coins_done": "{emoji} **{name}**: {verb} {coin} → saldo **{balance}**\n📝 Põhjus: {reason}",
|
||||
"coins_dm": "{emoji} Admin muutis sinu TipiCOINide saldot: **{verb} {coin}**\n📝 Põhjus: *{reason}*\nUus saldo: **{balance} {coin}**",
|
||||
"jail_done": "🚔 **{name}** on vangistatud {minutes} minutiks (vabaneb {ts}).\n📝 Põhjus: {reason}",
|
||||
"jail_dm": "🚔 Admin saatis sind vanglasse **{minutes} minutiks**.\n📝 Põhjus: *{reason}*\nVabaneb {ts}.",
|
||||
"unjail_done": "✅ **{name}** on vanglast vabastatud.",
|
||||
"unjail_dm": "✅ Admin vabastas sind vanglast.",
|
||||
"ban_done": "🚫 **{name}** keelati majandussüsteemis osaleda.\n📝 Põhjus: {reason}",
|
||||
"ban_dm": "🚫 Sul keelati TipiBOTi majandussüsteemis osalemine.\n📝 Põhjus: *{reason}*",
|
||||
"unban_done": "✅ **{name}** on majandussüsteemi osalemise keelust eemaldatud.",
|
||||
"unban_dm": "✅ Sinu TipiBOTi majandussüsteemis osalemise keeld on tühistatud. Saad taas käske kasutada.",
|
||||
"reset_done": "🗑️ **{name}** majandusandmed on lähtestatud.\n📝 Põhjus: {reason}",
|
||||
"reset_dm": "🗑️ Admin lähtestas sinu TipiBOTi majandusandmed (saldo, esemed, streak).\n📝 Põhjus: *{reason}*",
|
||||
"exp_done": "{emoji} **{name}**: {verb} EXP → kokku **{exp} EXP** (Tase {level}).\n📝 Põhjus: {reason}",
|
||||
"exp_dm": "{emoji} Admin muutis sinu EXP-i: **{verb} EXP**\n📝 Põhjus: *{reason}*\nUus EXP: **{exp}** (Tase {level})",
|
||||
"item_given": "✅ **{item}** antud kasutajale **{name}** (tasuta).",
|
||||
"item_removed":"🗑️ **{item}** eemaldatud kasutajalt **{name}**.",
|
||||
"item_invalid":"❌ Tundmatu ese: `{item_id}`. Kontrolli `/shop` eseme ID-d.",
|
||||
"item_not_owned": "❌ **{name}** ei oma eset `{item_id}`.",
|
||||
"item_dm_given": "✅ Admin andis sulle eseme: **{item}**.",
|
||||
"item_dm_removed":"🗑️ Admin eemaldas sult eseme: **{item}**.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /adminseason strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEASON: dict[str, str] = {
|
||||
"top_header": "**Top mängijad EXP järgi:**",
|
||||
"no_players": "Keegi ei teeninud EXP-i.",
|
||||
"entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||
"footer": "Kõigi EXP, mündid ja esemed lähtestati. Uus hooaeg algab kohe!",
|
||||
"done": "✅ Hooaeg lõpetatud - EXP, mündid ja esemed lähtestatud.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prestige system strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRESTIGE_SHOP_NAMES: dict[str, str] = {
|
||||
"coin_mult": "Mündiboost",
|
||||
"exp_mult": "EXP-boost",
|
||||
"daily_plus": "Päevabonus+",
|
||||
"work_plus": "Töötaja+",
|
||||
}
|
||||
PRESTIGE_SHOP_DESCRIPTIONS: dict[str, str] = {
|
||||
"coin_mult": "Kõik TipiCOINide teenimisallikad (daily, töö, kerja, kala) teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||
"exp_mult": "Kõik EXP allikad teenivad +8% rohkem iga taseme kohta. Max 5 taset → +40%.",
|
||||
"daily_plus": "Päevase boonuse alussumma tõuseb +20% iga taseme kohta. Max 3 taset → +60%.",
|
||||
"work_plus": "/work teenib +20% rohkem iga taseme kohta. Max 3 taset → +60%.",
|
||||
}
|
||||
PRESTIGE_UI: dict[str, str] = {
|
||||
"confirm_desc": (
|
||||
"Oled tasemel **{level}** ({exp} EXP).\n\n"
|
||||
"Prestiiži korral saad **{pp}** " + E["TipiFIRE"] + " ja kõik lähtestub:\n"
|
||||
"• Saldo, EXP, esemed, ooteajad\n\n"
|
||||
"**Kalakogu jääb alles!**\n\nKas oled kindel?"
|
||||
),
|
||||
"btn_confirm": "🔥 Jah, prestiiži!",
|
||||
"btn_cancel": "❌ Tühista",
|
||||
"btn_tab_status": "⭐ Prestiiz",
|
||||
"btn_tab_shop": "🛍️ Uuendused",
|
||||
"success_desc": (
|
||||
"Said **{pp}** " + E["TipiFIRE"] + "\n"
|
||||
"Prestiiži tase: **{level}**\n"
|
||||
"Kogutud PP: **{total_pp}** " + E["TipiFIRE"] + "\n\n"
|
||||
"*Kõik lähtestati. Alusta otsast!*"
|
||||
),
|
||||
"too_low_desc": "Prestiiži jaoks vajad taset **{required}** (sul on tase {level}).",
|
||||
"shop_desc": "Sul on **{pp}** " + E["TipiFIRE"] + " · Vajuta nuppu uuenduse ostmiseks",
|
||||
"shop_maxed": "✅ Max",
|
||||
"shop_level_fmt": "Tase {cur}/{max}",
|
||||
"shop_cost_fmt": "{cost} " + E["TipiFIRE"],
|
||||
"buy_success_desc":"**{name}** uuendatud tasemele **{new_level}/{max_level}**!\nPP alles: **{pp}** " + E["TipiFIRE"],
|
||||
"buy_no_pp": E["TipICRY"] + " Sul pole piisavalt PP. Sul on **{have}**, vajad **{need}** " + E["TipiFIRE"] + ".",
|
||||
"buy_maxed": "❌ See uuendus on juba maksimumtasemel.",
|
||||
"buy_not_found": "❌ Sellist uuendust ei leitud. Vaata `/prestigeshop`.",
|
||||
"rank_line": E["TipiFIRE"] + " Prestiiž **{level}** · {pp} PP",
|
||||
"rank_season": "🏆 Hooaja EXP: **{exp}**",
|
||||
"btn_buy_upgrade": "{emoji} {name} +1 ({cost} PP)",
|
||||
"status_footer": "⭐ Prestiiž {level} · {pp} PP",
|
||||
}
|
||||
226
strings/commands.py
Normal file
226
strings/commands.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Slash-command and /help metadata (descriptions, options, categories).
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
from core.emoji import EMOJI as E
|
||||
|
||||
__all__ = [
|
||||
'CMD',
|
||||
'OPT',
|
||||
'HELP_CATEGORIES',
|
||||
'HELP_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slash command descriptions (shown in Discord's autocomplete)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CMD: dict[str, str] = {
|
||||
"ping": "Vaata, kas bot on üleval",
|
||||
"help": "Nimekiri kõikidest käskudest kategooriate kaupa",
|
||||
"status": "Näita boti staatust ja ressursside kasutust",
|
||||
"birthdays": "Näita kõigi liikmete sünnipäevi kuu järgi",
|
||||
"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)",
|
||||
"send": "Saada sõnum valitud kanalisse",
|
||||
"profile": "Vaata oma profiili: saldo, tase, esemed, statistika ja kalakogu",
|
||||
"balance": "Vaata enda (või kellegi teise) TipiCOINide saldot",
|
||||
"daily": "Võta enda päevane TipiCOINi boonus",
|
||||
"work": "Tööta ja teeni TipiCOINe (1h ooteaeg)",
|
||||
"beg": "Kerja TipiCOINe (5min ooteaeg)",
|
||||
"crime": "Proovi oma õnne kriminaalse tegevusega (2h ooteaeg)",
|
||||
"rob": "Proovi kelleltki TipiCOINe varastada",
|
||||
"heist": "Alusta grupirööv pangahoidlasse (min 2 mängijat, max 8, 4h serveri ooteaeg)",
|
||||
"roulette": "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)",
|
||||
"give": "Anna TipiCOINe teisele mängijale",
|
||||
"leaderboard": "TipiBOTi edetabel - kes on kõige rikkam?",
|
||||
"shop": "Sirvi TipiBOTi poodi",
|
||||
"buy": "Osta ese TipiBOTi poodist",
|
||||
"rps": "Kivi-paber-käärid mõne teise mängija vastu",
|
||||
"slots": "Proovi oma õnne TipiBOTi mänguautomaadiga",
|
||||
"request": "Kerja TipiCOINe teistelt mängijatelt - nagu crowdfunding, aga halvem",
|
||||
"reminders": "Halda DM meeldetuletusi - kõik on vaikimisi sees, lülita need siin välja",
|
||||
"cooldowns": "Vaata kõikide käskude ooteaegu",
|
||||
"jailed": "Vaata, kes on praegu vanglas",
|
||||
"rank": "Vaata oma EXP, taset ja edetabeli positsiooni",
|
||||
"stats": "Vaata oma mängustatistikat",
|
||||
"jailbreak": "Proovi vanglast põgeneda kasutades täringuid",
|
||||
"adminseason": "[Admin] Lõpeta võistlus, teavita võitjaid ja lähtesta EXP",
|
||||
"admincoins": "[Admin] Anna või võta TipiCOINe kasutajale/kasutajalt",
|
||||
"adminjail": "[Admin] Saada kasutaja vangi",
|
||||
"adminunjail": "[Admin] Vabasta kasutaja vangist",
|
||||
"adminban": "[Admin] Keela kasutajal majandussüsteemis osaleda",
|
||||
"adminunban": "[Admin] Eemalda majandussüsteemi keeld kasutajalt",
|
||||
"adminreset": "[Admin] Lähtesta kasutaja majandusandmed",
|
||||
"adminview": "[Admin] Vaata kasutaja majandusandmeid",
|
||||
"adminexp": "[Admin] Anna v\u00f5i v\u00f5ta EXP kasutajalt",
|
||||
"adminitem": "[Admin] Anna v\u00f5i eemalda ese kasutajalt (tasuta)",
|
||||
"allowchannel": "[Admin] Lisa kanal, kus bot võib vastata käsklustele",
|
||||
"denychannel": "[Admin] Eemalda kanal lubatud kanalite nimekirjast",
|
||||
"channels": "[Admin] Näita lubatud kanalite nimekirja",
|
||||
"economysetup": "[Admin] Loo ja sea korda majandussüsteemi rollid",
|
||||
"blackjack": "Mängi blackjacki TipiBOTi vastu",
|
||||
"prestige": "Prestiiži (nõuab taset 30) ja teeni Prestiižipunkte",
|
||||
"prestigeshop": "Vaata prestiižipoodi ja sinu uuenduste taset",
|
||||
"prestigebuy": "Osta prestiižiuuendus Prestiižipunktide eest",
|
||||
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
|
||||
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
|
||||
"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)",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Option descriptions (shown next to each parameter in Discord)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OPT: dict[str, str] = {
|
||||
"admin_kasutaja": "Kasutaja",
|
||||
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
||||
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
||||
"adminjail_minutid": "Vangistamise kestus minutites",
|
||||
"balance_kasutaja": "Mängija, kelle saldot vaadata (vaikimisi sina)",
|
||||
"roulette_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||
"roulette_värv": "Punane, must või roheline",
|
||||
"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)",
|
||||
"request_summa": "Kui palju TipiCOINe vajad?",
|
||||
"request_põhjus": "Miks sa vajad TipiCOINe?",
|
||||
"request_sihtmärk": "Valikuline: kellelt täpselt palud (teised ei saa toetada)",
|
||||
"send_kanal": "Kanal, kuhu sõnum saata",
|
||||
"send_sõnum": "Sõnum, mida saata",
|
||||
"allowchannel_kanal": "Kanal, kus bot võib vastata käsklustele",
|
||||
"denychannel_kanal": "Kanal, kus bot ei või vastata käsklustele",
|
||||
"rob_sihtmärk": "Kellelt röövid?",
|
||||
"member_user": "Mängija, kelle infot tahad vaadata",
|
||||
"rank_kasutaja": "Mängija, kelle taset tahad vaadata (vaikimisi sina)",
|
||||
"stats_kasutaja": "Mängija, kelle statistikat tahad vaadata (vaikimisi sina)",
|
||||
"adminseason_top_n": "Kui palju mängijaid võitis (vaikimisi 10)",
|
||||
"blackjack_panus": "Panus TipiCOINides ('all' = kogu saldo)",
|
||||
"prestigebuy_upgrade": "Uuenduse ID (vaata /prestigeshop)",
|
||||
"fishbook_kasutaja": "Mängija, kelle kalakogu vaadata (vaikimisi sina)",
|
||||
"profile_kasutaja": "Mängija, kelle profiili vaadata (vaikimisi sina)",
|
||||
"adminexp_kogus": "Positiivne = anna, negatiivne = võta",
|
||||
"adminitem_ese": "Eseme ID (kasutatav sisse, vaata /shop)",
|
||||
"adminitem_tegevus": "'anna' või 'eemalda'",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /help categories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_CATEGORIES: dict[str, dict] = {
|
||||
"üldine": {
|
||||
"label": "🤖 Üldine",
|
||||
"description": "Üldised käsklused",
|
||||
"color": 0x57F287,
|
||||
"fields": [
|
||||
("/ping", "Vaata, kas bot on üleval"),
|
||||
("/status", "Näita boti staatust ja ressursside kasutust"),
|
||||
("/help", "Nimekiri kõikidest käskudest kategooriate kaupa"),
|
||||
("/birthdays", "Näita kõigi liikmete sünnipäevi kuu järgi"),
|
||||
],
|
||||
},
|
||||
"tipibot": {
|
||||
"label": "🪙 TipiBOT",
|
||||
"description": "TipiCOIN majandus",
|
||||
"color": 0xF4C430,
|
||||
"fields": [
|
||||
("/profile [@user]", "Saldo, tase, EXP progress, prestiiz - kõik ühes kohas. Nupud: Esemed · Statistika · Kalakogu."),
|
||||
("/cooldowns", "Vaata kõikide käskude ooteaegu. Näitab ka vangla ooteaega."),
|
||||
("/daily", "Võta enda päevane TipiCOINide boonus. 20h ooteaeg. Streak'i boonus: 3d=+50%, 7d=+100%, 14d=+200%."),
|
||||
("/work", "Tööta ja teeni TipiCOINe (1h ooteaeg)"),
|
||||
("/beg", "Kerja TipiCOINe (5min ooteaeg)"),
|
||||
("/crime", "Proovi oma õnne kriminaalse tegevusega. 60% edu, 40% trahv + 30min vanglas. 2h ooteaeg."),
|
||||
("/rob @user", "Proovi kelleltki TipiCOINe varastada. 45% edu. Ebaõnnestumisel saad trahvi."),
|
||||
("/heist", "Alusta grupiröövi pangahoidlasse. Min 2 mängijat, max 8. 5 min ühinemisaeg. Õnnestumisel jagatakse saak võrdselt - ebaõnnestumisel 1h 30min vangis + trahv. 4h serveri ooteaeg (ei ole isiklik)."),
|
||||
("/jailbreak", "Proovi vanglas olles täringuid visata, et duublit saada (3 katset). Duubli korral saad vabaks. Ebaõnnestumisel saad valida: maksa kautsjon (20-30% saldost, min 350 ⬡) või jää vanglasse kuni aja lõpuni."),
|
||||
("/give @user <amount>", "Anna TipiCOINe teisele mängijale"),
|
||||
("/quests", "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad (uueneb iga päev/nädal)."),
|
||||
("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"),
|
||||
("/shop", "Sirvi TipiBOTi poodi"),
|
||||
("/buy <item>", "Osta ese TipiBOTi poodist"),
|
||||
("/request <amount> <reason> [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."),
|
||||
("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."),
|
||||
],
|
||||
},
|
||||
"shop": {
|
||||
"label": "🛍️ Pood",
|
||||
"description": "TipiBOTi poe esemed ja nende efektid",
|
||||
"color": 0xF4C430,
|
||||
"fields": [
|
||||
(f"{E['TipiHIIR']} Mängurihiir - 500 ⬡", "Teeni töötades 50% rohkem TipiCOINe."),
|
||||
(f"{E['TipiMATT']} XL hiirematt - 600 ⬡", "Kerjamise ooteaeg 5min → 3min."),
|
||||
(f"{E['TipiKLAPID']} Kõrvaklapid - 1200 ⬡", "Päevase boonuse ooteaeg 20h → 18h."),
|
||||
(f"{E['TipiPILET']} LAN pilet (2025) - 1200 ⬡", "Päevane boonus on duubeldatud."),
|
||||
(f"{E['TipiVAC']} Anticheat - 750 ⬡", "Röövimine sinu vastu ebaõnnestub. Pärast 2 kasutust pead ostma uue."),
|
||||
(f"{E['TipiBULL']} Red Bull - 800 ⬡", "30% tõenäosus, et teenid töötades 3x rohkem."),
|
||||
(f"{E['TipiLAP']} Botikoobas - 1500 ⬡", "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt."),
|
||||
(f"{E['TipiLAUD']} Reguleeritav laud - 3500 ⬡ *(T2)*", "/work teenib 25% rohkem (stackib mängurihiirega)."),
|
||||
(f"{E['TipiSERVER']} Jellyfin server - 4000 ⬡ *(T2)*", "Röövimise edu tõenäosus 45% → 60%."),
|
||||
(f"{E['TipiMIC']} Mikrofon - 2800 ⬡ *(T2)*", "Teeni 30% rohkem eduka /crime puhul."),
|
||||
(f"{E['TipiKLAVA']} Mehhaaniline klaviatuur - 1800 ⬡ *(T2)*", "/beg teenib 2x rohkem."),
|
||||
(f"{E['TipiMONITOR']} Ultralai monitor - 2500 ⬡ *(T2)*", "/work ooteaeg: 1h → 40min."),
|
||||
(f"{E['TipiCAT']} CAT6 netikaabel - 3500 ⬡ *(T2)*", "/crime edu tõenäosus tõuseb 60% → 75%."),
|
||||
(f"{E['TipiMONITOR2']} 360hz monitor - 7500 ⬡ *(T3)*", "Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x."),
|
||||
(f"{E['TipiKARIKAS']} TipiLANi trofee - 6000 ⬡ *(T3)*", "Streak ei nulli, kui sa mõne päeva vahele jätad."),
|
||||
(f"{E['TipiTOOL']} Mänguritool - 9000 ⬡ *(T3)*", "/crime ebaõnnestumine ei saada sind vanglasse."),
|
||||
],
|
||||
},
|
||||
"games": {
|
||||
"label": "🎮 Mängud",
|
||||
"description": "Lõbusad mängud",
|
||||
"color": 0x5865F2,
|
||||
"fields": [
|
||||
("/roulette <bet> <colour>", "Panusta TipiCOINe punasele, mustale või rohelisele (1/37 võimalus, 14x payout)"),
|
||||
("/rps [bet] [opponent]", "Kivi-paber-käärid mõne teise mängija vastu"),
|
||||
("/slots <bet>", "Proovi oma õnne TipiBOTi mänguautomaadiga"),
|
||||
("/blackjack <bet>", "Mängi blackjacki TipiBOTi vastu. Blackjack maksab 3:2. Kakskordistamine (double down) on võimalik vaid esimese käigu ajal."),
|
||||
],
|
||||
},
|
||||
"admin": {
|
||||
"label": "🔧 Admin",
|
||||
"description": "Admin käsklused (peidetud tavaliste kasutajate eest)",
|
||||
"color": 0xED4245,
|
||||
"fields": [
|
||||
("/check", "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed"),
|
||||
("/member @user", "Näita liikme andmeid tabelist"),
|
||||
("/sync", "Sünkroniseeri käsklused Discordi serveriga"),
|
||||
("/restart", "Tee taaskäivitus botile"),
|
||||
("/send #channel message", "Saada sõnum valitud kanalisse"),
|
||||
("/admincoins @user <amount> <reason>", "Anna või võta TipiCOINe kasutajale/kasutajalt"),
|
||||
("/adminjail @user <minutes> <reason>", "Saada kasutaja vangi"),
|
||||
("/adminunjail @user", "Vabasta kasutaja vangist"),
|
||||
("/adminban @user <reason>", "Keela kasutajal majandussüsteemis osaleda"),
|
||||
("/adminunban @user", "Eemalda majandussüsteemi keeld kasutajalt"),
|
||||
("/adminreset @user <reason>", "Lähtesta kasutaja majandusandmed"),
|
||||
("/adminview @user", "Vaata kasutaja majandusandmeid"),
|
||||
("/allowchannel #channel", "Lisa kanal, kus bot võib vastata käsklustele"),
|
||||
("/denychannel #channel", "Eemalda kanal lubatud kanalite nimekirjast"),
|
||||
("/channels", "Näita lubatud kanalite nimekirja"),
|
||||
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"),
|
||||
("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /help UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_UI: dict[str, str] = {
|
||||
"footer": "TipiBOT • Vali kategooria allmenüüst",
|
||||
"select_placeholder": "Vali kategooria...",
|
||||
}
|
||||
247
strings/common.py
Normal file
247
strings/common.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Shared / system-wide strings (messages, titles, errors, cooldowns).
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
from core.emoji import EMOJI as E
|
||||
|
||||
__all__ = [
|
||||
'MSG_BANNED',
|
||||
'MSG_SPAM_JAIL',
|
||||
'MSG_PONG',
|
||||
'MSG_RESTART_DONE',
|
||||
'MSG_RESTARTING',
|
||||
'MSG_SHUTTING_DOWN',
|
||||
'MSG_PAUSED',
|
||||
'MSG_UNPAUSED',
|
||||
'MSG_MAINTENANCE',
|
||||
'MSG_SYNC_DONE',
|
||||
'MSG_REMINDER',
|
||||
'MSG_LEVELUP',
|
||||
'MSG_LEVELUP_ROLE',
|
||||
'REMINDER_OPTS',
|
||||
'TITLE',
|
||||
'ERR',
|
||||
'CD_MSG',
|
||||
'REMINDERS_UI',
|
||||
'SEND_UI',
|
||||
'PATCHNOTES_UI',
|
||||
'STATUS_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repeated system messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MSG_BANNED = "🚫 Sul keelati TipiBOTi majandussüsteemis osaleda."
|
||||
MSG_SPAM_JAIL = "⚠️ Liiga kiire! Automaatsed skriptid/spam on keelatud. Oled **30 minutit vangis**. Kasuta `/jailbreak`, et varem välja pääseda."
|
||||
MSG_PONG = "🏓 Pong!"
|
||||
MSG_RESTART_DONE = "✅ Bot on taaskäivitatud!"
|
||||
MSG_RESTARTING = "🔄 Taaskäivitan..."
|
||||
MSG_SHUTTING_DOWN = "⛔ Lülitan boti välja..."
|
||||
MSG_PAUSED = "⏸️ Bot on hooldusrežiimis - kõik käsklused on peatatud."
|
||||
MSG_UNPAUSED = "▶️ Hooldusrežiim lõpetatud - käsklused on taas saadaval."
|
||||
MSG_MAINTENANCE = "🔧 Bot on hetkel hoolduses. Proovi mõne hetke pärast uuesti."
|
||||
MSG_SYNC_DONE = "✅ Käsud sünkroniseeritud!"
|
||||
MSG_REMINDER = "⏰ **/{cmd}** on taas kasutamiseks valmis!\n-# Meeldetuletuste haldamiseks kasuta serveris `/reminders`."
|
||||
MSG_LEVELUP = "⬆️ **{name}** jõudis **{level}. tasemele**!{extra}"
|
||||
MSG_LEVELUP_ROLE = " 🎊 Uus tiitel: **{role}**!"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reminders dropdown options (cmd_key, label, description)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REMINDER_OPTS: list[tuple[str, str, str]] = [
|
||||
("daily", "📅 /daily", "Päevane boonus (20t ooteaeg)"),
|
||||
("work", "💼 /work", "Töö (1t ooteaeg, 40min monitoriga)"),
|
||||
("beg", "🙏 /beg", "Kerjamine (5min ooteaeg)"),
|
||||
("crime", "🦹 /crime", "Kuritegu (2t ooteaeg)"),
|
||||
("rob", "🔫 /rob", "Rööv (2t ooteaeg)"),
|
||||
("fish", "🎣 /fish", "Kalapüük (2min ooteaeg)"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embed titles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TITLE: dict[str, str] = {
|
||||
"daily": "📅 Päevane boonus",
|
||||
"work": "💼 Töö",
|
||||
"beg": "🙏 Kerjamine",
|
||||
"crime_win": f"{E['TipiFIRE']} Kuritegu õnnestus!",
|
||||
"crime_fail": f"{E['TipiTROLL']} Vahele jäid!",
|
||||
"rob_win": f"{E['TipiFIRE']} Rööv õnnestus!",
|
||||
"rob_fail": f"{E['TipiTROLL']} Rööv ebaõnnestus!",
|
||||
"rob_anticheat": f"{E['TipiVAC']} Anticheat peatas sind!",
|
||||
"jailbreak": "🎲 Vanglast põgenemine",
|
||||
"jailbreak_free": f"🎲 {E['TipiFIRE']} DUUBEL! Oled vaba!",
|
||||
"jailbreak_fail": f"{E['TipICRY']} Kolm katset läbi!",
|
||||
"jailbreak_miss": "🎲 " + E["TipICRY"] + " Ei saanud duublit ({tries}/{max})",
|
||||
"jailbreak_bail": "💸 Kautsjon",
|
||||
"give": f"{E['TipiHEART']} TipiCOINi ülekanne",
|
||||
"stats": "📊 Mängustatistika",
|
||||
"leaderboard_coins": "🪙 TipiBOTi edetabel - Mündid",
|
||||
"leaderboard_exp": "📊 TipiBOTi edetabel - EXP / Tase",
|
||||
"leaderboard_season": "🏆 TipiBOTi edetabel - Hooaja EXP",
|
||||
"leaderboard_prestige": f"{E['TipiFIRE']} TipiBOTi edetabel - Prestiiž",
|
||||
"leaderboard_wagered": "🎲 TipiBOTi edetabel - Hasartmängud",
|
||||
"leaderboard_fish": "🎣 TipiBOTi edetabel - Kalapüük",
|
||||
"rps": "⚔️ Kivi, Paber, Käärid",
|
||||
"rps_duel": "⚔️ KPK duell",
|
||||
"rps_duel_active": "⚔️ KPK duell - käimas",
|
||||
"rps_duel_done": "⚔️ KPK duell - lõppenud",
|
||||
"rps_duel_cancel": "⚔️ KPK duell - tühistatud",
|
||||
"rps_duel_expire": "⚔️ KPK duell - aegus",
|
||||
"rps_duel_decline": "⚔️ KPK duell - keelduti",
|
||||
"heist_lobby": "🔫 Grupirööv - kogunemine",
|
||||
"heist_win": f"{E['TipiFIRE']} Grupirööv õnnestus!",
|
||||
"heist_fail": f"{E['TipiSKULL']} Grupirööv ebaõnnestus!",
|
||||
"heist_cancel": "🔫 Grupirööv tühistatud",
|
||||
"request": f"{E['TipiHEART']} Rahataotlus",
|
||||
"reminders": "⏰ Meeldetuletused",
|
||||
"cooldowns": "⏱️ Sinu ooteajad",
|
||||
"adminseason": "🏆 Hooaeg lõppes!",
|
||||
"economysetup": "⚙️ Majanduse seadistamine",
|
||||
"blackjack": "🃏 Blackjack",
|
||||
"blackjack_bj": f"🃏 {E['TipiFIRE']} BLACKJACK!",
|
||||
"blackjack_win": f"{E['TipiFIRE']} Võitsid!",
|
||||
"blackjack_lose": f"{E['TipiSKULL']} Kaotasid!",
|
||||
"blackjack_bust": f"{E['TipiSKULL']} Üle 21 - kaotasid!",
|
||||
"blackjack_push": "🤝 Viik!",
|
||||
"blackjack_dbust": f"{E['TipiSKULL']} Üle 21 - mõlemad kaotasid!",
|
||||
"blackjack_dwin": f"{E['TipiFIRE']} Topeltpanus võitis!",
|
||||
"prestige_confirm": "🔥 Prestiiž - kinnita",
|
||||
"prestige_success": E["TipiFIRE"] + " Prestiiž {level} saavutatud!",
|
||||
"prestige_too_low": "❌ Prestiiž pole saadaval",
|
||||
"prestige_shop": f"{E['TipiFIRE']} Prestiižipood",
|
||||
"prestige_buy_ok": "✅ Uuendus ostetud!",
|
||||
"fish_cast": "🎣 Otsid kala...",
|
||||
"fish_bite": "🐟 KALA NÄKKAB!",
|
||||
"fish_escape": "🎣 Kala pääses!",
|
||||
"fish_junk": "🗑️ Ai ai ai...",
|
||||
"fishbook": "📖 Kalakogu",
|
||||
"quests": "🎯 Ülesanded",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error / validation messages (use .format(**kwargs) for dynamic parts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ERR: dict[str, str] = {
|
||||
"positive_bet": "❌ Panus peab olema positiivne.",
|
||||
"positive_amount": "❌ Summa peab olema positiivne.",
|
||||
"positive_duration": "❌ Aeg peab olema positiivne.",
|
||||
"admincoins_zero": "Kogus ei tohi olla 0.",
|
||||
"missing_perms": "❌ Sul on vaja **Rollide haldamise** õigust selle käsu kasutamiseks.",
|
||||
"generic_error": "❌ Tekkis viga: ```{error}```",
|
||||
"send_failed": "❌ Sõnumi saatmine ebaõnnestus: ```{error}```",
|
||||
"rob_self": "❌ Sa ei saa iseennast röövida.",
|
||||
"rob_bot": "❌ Botti ei saa röövida.",
|
||||
"rob_no_house": "❌ Kassa pole veel avatud.",
|
||||
"rob_house_blocked": "❌ Panga seif on liiga hästi kaitstud. Kasuta `/heist` selle rüüstamiseks.",
|
||||
"rob_too_poor": "❌ **{name}** on liiga vaene röövimiseks.",
|
||||
"rob_target_jailed": "❌ **{name}** on vanglas - vabad inimesed ei saa vanglas olevaid röövida.",
|
||||
"heist_active": "❌ Serveris on juba aktiivne grupirööv käimas! Oota, kuni see lõpeb.",
|
||||
"heist_full": "❌ Grupirööv on täis!",
|
||||
"heist_min_players": "❌ Grupiröövi alustamiseks on vaja vähemalt **{min}** osalejat.",
|
||||
"broke": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Saldo: {bal}",
|
||||
"broke_need": E["TipICRY"] + " Sul pole piisavalt TipiCOINe. Vajad veel {need}.",
|
||||
"item_owned": "❌ Sul on see ese juba olemas.",
|
||||
"item_not_found": "❌ Eset ei leitud.",
|
||||
"item_level_req": "🔒 Selle eseme ostmiseks vajad **taset {min_level}** (sul on tase {user_level}). Teeni EXP-id kõiki käske kasutades.",
|
||||
"not_your_game": "❌ See pole sinu mäng!",
|
||||
"game_in_progress": "❌ Sul on juba mäng käimas! Lõpeta see enne.",
|
||||
"not_your_challenge":"❌ See väljakutse pole sulle!",
|
||||
"not_your_menu": "❌ See ei ole sinu menüü.",
|
||||
"give_self": "❌ Sa ei saa iseendale TipiCOINe anda.",
|
||||
"give_bot": "❌ Botile ei saa TipiCOINe anda.",
|
||||
"give_jailed": "❌ Oled vangis - vanglas ei saa TipiCOINe anda. Pääsed välja {ts}.",
|
||||
"rps_self": "❌ Sa ei saa iseendale väljakutset esitada.",
|
||||
"rps_bot": "❌ Botid on KPK-is liiga head.",
|
||||
"not_jailed": "❌ Sa pole praegu vangis.",
|
||||
"not_in_leaderboard":"❌ Sind pole veel edetabelis.",
|
||||
"admin_ban_bot": "❌ Botti ei saa bannida.",
|
||||
"admin_reset_bot": "❌ Kassa andmeid ei saa lähtestada.",
|
||||
"member_not_found": "❌ **{name}** ei leitud tabelist.",
|
||||
"request_self_fund": "❌ Sa ei saa oma taotlust ise rahastada.",
|
||||
"request_self": "❌ Sa ei saa iseendalt anuda.",
|
||||
"request_bot": "❌ Botid on teatavasti kitsid.",
|
||||
"request_targeted": "❌ See taotlus on suunatud kasutajale **{name}**.",
|
||||
"request_closed": "❌ See taotlus on juba rahastatud või aegunud.",
|
||||
"already_in_game": "❌ Sul on juba aktiivne mäng käimas!",
|
||||
"invalid_amount": "❌ Sisesta kehtiv summa või 'all'.",
|
||||
"fund_range": "❌ Sisesta summa vahemikus 1-{max}.",
|
||||
"channel_only": "❌ Boti käske saab kasutada ainult nendes kanalites: {channels}",
|
||||
"guild_only": "Seda käsku saab kasutada ainult serveris.",
|
||||
"sheet_error": "❌ Tabeli laadimine ebaõnnestus: ```{error}```",
|
||||
"gamble_cooldown": "🎰 Oled just mänginud! Saad uuesti mängida {ts}.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cooldown messages (use .format(ts=...) - ts is a Discord timestamp string)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CD_MSG: dict[str, str] = {
|
||||
"daily": "⏳ Järgmine boonus {ts}.",
|
||||
"work": "⏳ Saad töötada {ts}.",
|
||||
"beg": "⏳ Saad kerjata {ts}.",
|
||||
"crime": "⏳ Saad uuesti proovida {ts}.",
|
||||
"rob": "⏳ Saad uuesti röövida {ts}.",
|
||||
"heist": "⏳ Saad uuesti heisti teha {ts}.",
|
||||
"heist_global": "⏳ Pangahoidla alles kosub eelmisest röövist. Järgmine heist võimalik {ts}.",
|
||||
"jailed": E["TipiTROLL"] + " Oled vangis! Pääsed välja {ts}. Kasuta `/jailbreak`, et varem välja pääseda.",
|
||||
"fish": "🎣 Saad uuesti kalastada {ts}.",
|
||||
}
|
||||
REMINDERS_UI: dict[str, str] = {
|
||||
"select_placeholder": "Vali käsud, millest soovid meeldetuletust...",
|
||||
"saved_on": "✅ Meeldetuletused sisse lülitatud: {names}",
|
||||
"saved_off": "🔕 Kõik meeldetuletused välja lülitatud.",
|
||||
"desc_active": "Praegu aktiivne: {status}\n\nMuuda valikut allpool.",
|
||||
"desc_none": "Hetkel pole ühtegi meeldetuletust sisse lülitatud.\n\nVali allpool, millest soovid DM-i saada.",
|
||||
"footer": "Bot DM-ib sulle, kui valitud käsk on taas kasutamiseks valmis.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /send UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEND_UI: dict[str, str] = {
|
||||
"sent": "✅ Sõnum saadetud kanalisse {channel}!",
|
||||
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /patchnotes UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PATCHNOTES_UI: dict[str, str] = {
|
||||
"title": "📝 Muudatuste logi — {version}",
|
||||
"footer": "Versioon {idx}/{total}",
|
||||
"btn_newer": "◀ Uuem",
|
||||
"btn_older": "Vanem ▶",
|
||||
"select_placeholder": "Vali versioon…",
|
||||
"empty_file": "ℹ️ Muudatuste logi on hetkel tühi.",
|
||||
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /status UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATUS_UI: dict[str, str] = {
|
||||
"title": "🖥️ Boti olek",
|
||||
"uptime_field": "🕐 Uptime",
|
||||
"uptime_val": "{hours}t {minutes}m {seconds}s",
|
||||
"latency_field": "📡 Latency",
|
||||
"latency_val": "{ms} ms",
|
||||
"ram_field": "🧠 RAM (RSS)",
|
||||
"ram_val": "{mb} MB",
|
||||
"cpu_field": "⚙️ CPU",
|
||||
"cpu_val": "{percent}%",
|
||||
"tasks_field": "🔄 Async tasks",
|
||||
"eco_players_field": "👤 Eco players",
|
||||
"members_cache_field": "📋 Liikmed (cache)",
|
||||
"log_files_field": "📂 Log files",
|
||||
"log_line": "`{name}` - {size_kb} KB",
|
||||
"none": "-",
|
||||
}
|
||||
469
strings/economy.py
Normal file
469
strings/economy.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""Core economy: income, profile, shop, quests, leaderboard, requests.
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
from core.emoji import EMOJI as E
|
||||
|
||||
__all__ = [
|
||||
'WORK_JOBS',
|
||||
'BEG_LINES',
|
||||
'BEG_JAIL_LINES',
|
||||
'CRIME_WIN',
|
||||
'CRIME_LOSE',
|
||||
'QUEST_UI',
|
||||
'QUEST_DESCRIPTIONS',
|
||||
'SHOP_UI',
|
||||
'ITEM_DESCRIPTIONS',
|
||||
'CONSUMABLES_UI',
|
||||
'CONSUMABLE_DESCRIPTIONS',
|
||||
'JAILED_UI',
|
||||
'SHOP_BTN',
|
||||
'DAILY_UI',
|
||||
'STATS_UI',
|
||||
'PROFILE_UI',
|
||||
'BALANCE_UI',
|
||||
'COOLDOWNS_UI',
|
||||
'RANK_UI',
|
||||
'WORK_UI',
|
||||
'BEG_UI',
|
||||
'CRIME_UI',
|
||||
'ROB_UI',
|
||||
'GIVE_UI',
|
||||
'BUY_UI',
|
||||
'LEADERBOARD_UI',
|
||||
'REQUEST_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flavour text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WORK_JOBS: list[tuple[str, float]] = [
|
||||
("paigaldasid aulas netikaableid", 1.0),
|
||||
("paigaldasid aulas voolukaableid", 1.0),
|
||||
("tõid poest korraldajatele pitsat", 1.0),
|
||||
("konfigureerisid uue switchi", 1.0),
|
||||
("vedasid laudu aulasse", 1.0),
|
||||
("vedasid toole aulasse", 1.0),
|
||||
("paigaldasid aulasse vahekardinaid", 1.0),
|
||||
("aitasid üles seada turniiri lava", 1.0),
|
||||
("töötasid baaris", 1.0),
|
||||
("testisid helisüsteemi \"Erootikapoodi\" blastides", 1.0),
|
||||
("juhendasid külastajaid infolauas", 1.0),
|
||||
("seadistasid LAN serverit", 1.0),
|
||||
("paigaldasid monitore", 1.0),
|
||||
("ühendasid aulas kilpe", 1.0),
|
||||
("aitasid LANil osalejat", 1.0),
|
||||
("müüsid energiajooke", 1.0),
|
||||
("tegid netikaableid", 1.0),
|
||||
("lahendasid IP-konflikti", 1.0),
|
||||
("parandasid kellegi arvutit", 1.0),
|
||||
("tegid korda striimi heli (mitte nagu PGL)", 1.2),
|
||||
("kirjutasid koodi miniturniiri jaoks", 1.2),
|
||||
("võitsid turniiri", 1.5),
|
||||
]
|
||||
BEG_LINES: list[str] = [
|
||||
"istusid kurva näoga oma monitori taga",
|
||||
"palusid abi kellegi DMis",
|
||||
"postitasid #abi kanalisse",
|
||||
"vaatasid kadedusega teiste asju",
|
||||
"kirjutasid /beg kolmandat korda järjest",
|
||||
"küsisid oma tiimiliikmelt laenu",
|
||||
"võtsid kiirlaenu",
|
||||
"müüsid 3 sendi eest oma CSi skinne",
|
||||
"korjasid kampuselt taarat",
|
||||
"tegid TikToki live'i",
|
||||
"refreshisid pangaäppi lootuses, et raha tekib",
|
||||
"kirjutasid \"pls donate\" chatti",
|
||||
"vaatasid oma tühja rahakotti",
|
||||
"püüdsid kedagi guilt-trippida",
|
||||
"lubasid exposure'it raha eest",
|
||||
"mängisid kurba muusikat taustaks",
|
||||
"teesklesid, et see on sotsiaalne eksperiment",
|
||||
"kirjutasid emotsionaalse loo oma olukorrast",
|
||||
"pakkusid vastu future equity't",
|
||||
"tegite sõbraga koos kerjamisplaani",
|
||||
"otsisid diivani vahelt sente",
|
||||
"lubasid boostida kellegi ranki viieka eest",
|
||||
"küsisid chatist, kas keegi teab kust tasuta V-Buckse saab",
|
||||
"avastasid, et su krüptoportfell on sügavas miinuses",
|
||||
]
|
||||
BEG_JAIL_LINES: list[str] = [
|
||||
"karjusid läbi trellide, et keegi münte läbi lükkaks",
|
||||
"kirjutasid kongi seina peale oma PayPali aadressi",
|
||||
"lubasid, et maksad järgmine kord vangi minnes tagasi",
|
||||
"veensid valvurit, et oled tegelikult hea inimene",
|
||||
"saatsid vanglast käsitsi kirjutatud kirja oma meeskonnale",
|
||||
"pakkusid vahile tulevaste vihjete eest raha",
|
||||
"koputasid morset vastu naaberkongi seina",
|
||||
"üritasid vangla WiFit häkkida",
|
||||
"lubasid teha tasuta IT-tööd vabanemise eest",
|
||||
"üritasid lusikaga tunnelit kaevata",
|
||||
"kirjutasid bug reporti vangla süsteemile",
|
||||
"proovisid trellidest läbi pugeda, aga jäid kinni",
|
||||
"pakkusid vangivalvurile oma Steami kontot vabanemise eest",
|
||||
]
|
||||
CRIME_WIN: list[str] = [
|
||||
"häkkisid turniiri tulemuste tabelit",
|
||||
"varastasid oma vastase hiire",
|
||||
"varastasid oma vastase klaviatuuri",
|
||||
"varastasid oma vastase kõrvaklapid",
|
||||
"varastasid oma vastase monitori",
|
||||
"varastasid oma vastase mikrofoni",
|
||||
"müüsid võltsitud LANi pileteid",
|
||||
"laenatasid kellegi GPUd ja ei tagastanud seda",
|
||||
"laenatasid Filmiklubilt AUX-kaablit ja ei tagastanud seda",
|
||||
"käivitasid DDoS rünnaku serverile",
|
||||
"tegid oma shitcoin'idega pump and dump'i",
|
||||
"kasutasid aimbotti ja keegi ei märganud",
|
||||
"exploitisid mängu bugi enda kasuks",
|
||||
"phishisid kellegi Steami konto",
|
||||
"varastasid prize pooli raha",
|
||||
"ühendasid enda krüptokaevandaja serverisse",
|
||||
"tegid petukõnesid"
|
||||
]
|
||||
CRIME_LOSE: list[str] = [
|
||||
"jäid turniiri adminile vahele",
|
||||
"tõmbasid oma arvuti kaabli pistikust välja",
|
||||
"kukutasid lahtise Red Bulli omale sülle",
|
||||
"unustasid VPNi sisse lülitada",
|
||||
"lagisid kriitilisel momendil",
|
||||
"said banni otseülekande ajal",
|
||||
"unustasid logid kustutada",
|
||||
"sinu skript crashis valel hetkel",
|
||||
"said reportitud mitme mängija poolt",
|
||||
"sinu krüptokaevandaja süttis põlema",
|
||||
"flashisid tervet oma tiimi",
|
||||
"avastasid, et Windows otsustas keset mängu uuendama hakata",
|
||||
"jäid politseile petukõnedega vahele"
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quest system (/quests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QUEST_UI: dict[str, str] = {
|
||||
"daily_header": "📅 Päevaülesanded",
|
||||
"weekly_header": "📆 Nädalaülesanded",
|
||||
"progress": "⏳ {progress}/{max}",
|
||||
"ready": "✅ Valmis - nõua auhind!",
|
||||
"completed": "🏆 Nõutud",
|
||||
"reward": "🎁 {coins} ⬡ · {exp} EXP",
|
||||
"empty": "Ühtegi ülesannet pole. Proovi hiljem uuesti!",
|
||||
"claim_btn": "🎁 Nõua auhinnad",
|
||||
"claimed_msg": "🏆 Nõudsid {count} ülesande auhinnad: +{coins} · +{exp} EXP!",
|
||||
"nothing": "Sul pole ühtegi valmis ülesannet, mida nõuda.",
|
||||
"error": "❌ Ülesannete laadimine ebaõnnestus. Proovi hiljem uuesti.",
|
||||
}
|
||||
|
||||
# Estonian one-line description per quest id (keys match economy.QUESTS_*).
|
||||
QUEST_DESCRIPTIONS: dict[str, str] = {
|
||||
# Daily
|
||||
"work3": "Tööta 3 korda (/work)",
|
||||
"beg5": "Kerja 5 korda (/beg)",
|
||||
"wager500": "Panusta kokku 500 ⬡ hasartmängudes",
|
||||
"fish2": "Püüa 2 kala (/fish)",
|
||||
"crime1": "Soorita edukalt 1 kuritegu (/crime)",
|
||||
"earn1000": "Teeni kokku 1 000 ⬡",
|
||||
"give200": "Kingi teistele kokku 200 ⬡ (/give)",
|
||||
# Weekly
|
||||
"work20": "Tööta 20 korda (/work)",
|
||||
"fish15": "Püüa 15 kala (/fish)",
|
||||
"wager5000": "Panusta kokku 5 000 ⬡ hasartmängudes",
|
||||
"crime5": "Soorita edukalt 5 kuritegu (/crime)",
|
||||
"heist1": "Osale 1 grupiröövis (/heist)",
|
||||
"earn10000": "Teeni kokku 10 000 ⬡",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shop UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SHOP_UI: dict[str, str] = {
|
||||
"tier_1": "Tier 1 - Algaja",
|
||||
"tier_2": "Tier 2 - Kogenud",
|
||||
"tier_3": "Tier 3 - Legend",
|
||||
"desc": "Saldo: {bal} · Osta käsuga `/buy`",
|
||||
"owned": "✅ Olemas",
|
||||
"owned_uses_1": "✅ Olemas ({uses} kasutus järel)",
|
||||
"owned_uses_n": "✅ Olemas ({uses} kasutust järel)",
|
||||
"locked": "🔒 Tase {min_lvl} nõutud *(sul on {user_lvl})*",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shop item descriptions (keyed by SHOP dict key in economy.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ITEM_DESCRIPTIONS: dict[str, str] = {
|
||||
"gaming_hiir": "Koolist varastatud hiir? Ei, see on mängurihiir. Teeni töötades 50% rohkem TipiCOINe.",
|
||||
"hiirematt": "XXL suuruses, ainult parimast materjalist. Kerjamise ooteaeg 5min → 3min.",
|
||||
"korvaklapid": "Noise-cancelling - kuuled ainult TipiCOINide kõlinat. Päevase boonuse ooteaeg 20h → 18h + 25⬡ boonust.",
|
||||
"lan_pass": "Ametlik TipiLANi pilet (2025). Päevane boonus on duubeldatud.",
|
||||
"energiajook": "Kolm Red Bulli järjest. 30% tõenäosus, et teenid töötades 3x rohkem.",
|
||||
"gaming_laptop": "RTX 5090 jooksutab botte 24/7. Päevane boonus genereerib 5% intressi sinu saldo pealt.",
|
||||
"anticheat": "VAC, EAC, Faceit AC ja BattlEye korraga. Röövimine sinu vastu ebaõnnestub. **2 kasutust**, siis pead ostma uue.",
|
||||
"reguleeritav_laud": "Võid nii seista kui istuda - alati võidad. /work teenib 25% rohkem (stackib mängurihiirega).",
|
||||
"jellyfin": "Self-hosted meediaserver - oled suurfirmadest sõltumatu. Röövimise edu 45% → 60%. Grupiröövi õnnestumisele +5%.",
|
||||
"mikrofon": "Parem helikvaliteet teeb sind usutavamaks. Teeni 30% rohkem eduka /crime puhul.",
|
||||
"klaviatuur": "Klõbinad kostuvad üle kogu saali. /beg teenib 2x rohkem.",
|
||||
"monitor": "240Hz ja 27 tolli. /work ooteaeg: 1h → 40min.",
|
||||
"cat6": "Gigabitine internet = ideaalne piraatluseks. /crime edu tõenäosus tõuseb 60% → 75%.",
|
||||
"monitor_360": "360Hz, 1ms. Mänguautomaadi jackpot 10x → 15x, kolmik 4x → 6x. Hasartmängude ooteaeg 30s → 25s.",
|
||||
"karikas": "Ainult legendidele. Streak ei nulli, kui sa mõne päeva vahele jätad.",
|
||||
"gaming_tool": "Nii mugav, et isegi admin ei saa sind üles. /crime ebaõnnestumine ei saada sind vanglasse.",
|
||||
"ussipurk": "Lakkumatu toiduga ussipurk - kalad ei saa vastu. Kalapyygi ooteaeg 2min → 90s.",
|
||||
"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.",
|
||||
"entry": "• {mention} - vabaneb <t:{ts}:R>",
|
||||
"footer": "{count} vang{plural}",
|
||||
}
|
||||
SHOP_BTN: dict[int, str] = {
|
||||
1: "Tier 1",
|
||||
2: "Tier 2",
|
||||
3: "Tier 3",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /daily embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DAILY_UI: dict[str, str] = {
|
||||
"earned": "✅ Said {earned}!",
|
||||
"interest": E["TipiLAP"] + " Bot Farm tootis: +{interest}",
|
||||
"vip": f"{E['TipiPILET']} LAN pileti boonus rakendus!",
|
||||
"footer": "Streak: {streak_str} · Saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /stats embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATS_UI: dict[str, str] = {
|
||||
"economy_field": "💰 Majandus",
|
||||
"economy_val": "Tipp-saldo: {peak}\nEluaegne tulu: {earned}\nEluaegne kaotus: {lost}",
|
||||
"work_field": "🛠️ Töö & Kerja",
|
||||
"work_val": "Töötanud: **{work}** korda\nKerjanud: **{beg}** korda",
|
||||
"gamble_field": "🎲 Hasartmängud",
|
||||
"gamble_val": "Panustatud kokku: {wagered}\nSuurim võit: {win}\nSuurim kaotus: {loss}\nSlotsi jackpotid: **{jackpots}** korda",
|
||||
"crime_field": "🦹 Kuritegevus",
|
||||
"crime_val": "Kuriteod: **{crimes}** ({succeeded} õnnestus)\nHeistid: **{heists}** ({heists_won} võideti)\nVangi sattunud: **{jailed}** korda\nKautsjon makstud: {bail}",
|
||||
"social_field": "🤝 Sotsiaalne",
|
||||
"social_val": "Kingitud: {given}\nSaadud: {received}",
|
||||
"records_field": "🔥 Rekordid",
|
||||
"records_val": "Pikim päevane streak: **{streak}** päeva",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /profile tabbed view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILE_UI: dict[str, str] = {
|
||||
"btn_profile": "💰 Profiil",
|
||||
"btn_items": "🎒 Esemed",
|
||||
"btn_stats": "📊 Statistika",
|
||||
"btn_fish": "🎣 Kalakogu",
|
||||
"main_title": "💰 {name}",
|
||||
"items_title": "🎒 {name} - Esemed",
|
||||
"stats_title": "📊 {name} - Statistika",
|
||||
"fish_title": "🎣 {name} - Kalakogu",
|
||||
"items_empty": "Sul pole ühtegi eset.",
|
||||
"f_balance": "💰 Saldo",
|
||||
"f_level": "📊 Tase",
|
||||
"f_streak": "🔥 Streak",
|
||||
"f_prestige": "⭐ Prestiiz",
|
||||
"f_jail": "🚔 Vangis kuni",
|
||||
"f_progress": "→ Tase {next}",
|
||||
"progress_bar": "`{bar}` {done}/{needed} EXP",
|
||||
"level_val": "Tase {level} - {role}",
|
||||
"prestige_val":"⭐ P{level} · {pp} PP",
|
||||
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
|
||||
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
|
||||
"footer_t3": "T2 ja T3 pood avatud",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /balance embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BALANCE_UI: dict[str, str] = {
|
||||
"saldo": "Saldo",
|
||||
"streak": "Päeva streak",
|
||||
"streak_val": "🔥 {streak}p",
|
||||
"jailed_until": "🚔 Vangis kuni",
|
||||
"items": "Esemed",
|
||||
"uses_one": " *({uses} kasutus järel)*",
|
||||
"uses_many": " *({uses} kasutust järel)*",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /cooldowns embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COOLDOWNS_UI: dict[str, str] = {
|
||||
"ready": "✅ Valmis",
|
||||
"daily_line": "📅 **/daily** {status}{note}",
|
||||
"work_line": "💼 **/work** {status}{note}",
|
||||
"beg_line": "🙏 **/beg** {status}{note}",
|
||||
"crime_line": "🦹 **/crime** {status}",
|
||||
"rob_line": "🔫 **/rob** {status}",
|
||||
"fish_line": "🎣 **/fish** {status}{note}",
|
||||
"note_korvak": " *(kõrvaklapid: 18t)*",
|
||||
"note_monitor": " *(monitor: 40min)*",
|
||||
"note_hiirematt": " *(hiirematt: 3min)*",
|
||||
"note_ussipurk": " *(ussipurk: 90s)*",
|
||||
"jailed": "\n🔒 **Vanglas** - vabaneb <t:{ts}:R>",
|
||||
"jail_expired": "\n🔓 Vangla lõppes",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rank embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RANK_UI: dict[str, str] = {
|
||||
"title": "📊 {name} - Tase {level}",
|
||||
"field_title": "Tiitel",
|
||||
"field_exp": "EXP",
|
||||
"field_progress": "Progress → Tase {next}",
|
||||
"progress_val": "`{bar}` {progress}/{needed} EXP",
|
||||
"footer_t1": "Tase 10 avab T2 poe · Tase 20 avab T3 poe",
|
||||
"footer_t2": "T2 pood avatud · Tase 20 avab T3 poe",
|
||||
"footer_t3": "T2 ja T3 pood avatud",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /work embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WORK_UI: dict[str, str] = {
|
||||
"desc": "Sa {job} ja teenisid {earned}!",
|
||||
"redbull": f"\n{E['TipiBULL']} Red Bull aktiveerus - 3x boonus!",
|
||||
"hiir": f"\n{E['TipiHIIR']} Mängurihiir: +50% palk",
|
||||
"laud": f"\n{E['TipiLAUD']} Reguleeritav laud: +25% palk",
|
||||
"balance": "\nSaldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /beg embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BEG_UI: dict[str, str] = {
|
||||
"desc": "Sa {text} ja said {earned}.",
|
||||
"klaviatuur": f"{E['TipiKLAVA']} Mehhaaniline klaviatuur: 2x tulu",
|
||||
"balance": "Saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /crime embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CRIME_UI: dict[str, str] = {
|
||||
"win_desc": "Sa {text} ja teenisid {earned}!",
|
||||
"fail_base": "Sa {text} ja said trahvi {fine}.",
|
||||
"fail_jailed": "\n\ud83d\udd12 Oled vangis! P\u00e4\u00e4sed {ts}.",
|
||||
"fail_shield": "\n\ud83d\udee1\ufe0f Gaming Tool hoidis sind vanglast!",
|
||||
"mikrofon": f"\n{E['TipiMIC']} Mikrofon: +30% saak",
|
||||
"cat6": f"\n{E['TipiCAT']} CAT6: 75% edu t\u00f5en\u00e4osus",
|
||||
"balance": "\nSaldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rob embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROB_UI: dict[str, str] = {
|
||||
"jackpot_desc": "💥 **JACKPOT!** Murdsid TipiBOTi kassasse sisse ja varastasid {stolen}!\nSaldo: {balance}",
|
||||
"win_desc": "Varastasid {stolen} kasutajalt **{name}**!\nSaldo: {balance}",
|
||||
"anticheat_desc": "**{name}** kaitseb end Anticheati'ga - said trahvi {fine}.",
|
||||
"anticheat_worn": "⚠️ Sinu **Anticheat** on kulunud! Osta uus `/buy` käsuga.",
|
||||
"victim_dm": "💸 **{robber}** varastas sinult **{stolen}** münti!",
|
||||
"fail_desc": "Jäid vahele! Trahv: {fine}.\nSaldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /give embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GIVE_UI: dict[str, str] = {
|
||||
"desc": "**{giver}** andis {amount} kasutajale **{receiver}**.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buy embed strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUY_UI: dict[str, str] = {
|
||||
"title": "{emoji} {name} ostetud!",
|
||||
"desc": "{description}\nUus saldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /leaderboard UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LEADERBOARD_UI: dict[str, str] = {
|
||||
"house_entry": "🤖 {name} *(maja)* - {balance}",
|
||||
"house_default_name": "TipiBOT",
|
||||
"no_entries": "Keegi ei ole veel punkte teeninud.",
|
||||
"footer": "Lehekülg {page}/{total} · {count} mängijat",
|
||||
"btn_coins": "🪙 Mündid",
|
||||
"btn_exp": "📊 EXP",
|
||||
"btn_find_me": "📍 Mina",
|
||||
"exp_entry": "{prefix} {name} - {exp} EXP *(Tase {level})*",
|
||||
"unknown_user": "Kasutaja {uid}",
|
||||
"btn_season": "🏆 Hooaeg",
|
||||
"btn_prestige": "🔥 Prestiiž",
|
||||
"btn_wagered": "🎲 Hasartmäng",
|
||||
"btn_fish": "🎣 Kalapyyk",
|
||||
"season_entry": "{prefix} {name} - {exp} EXP *(Prestiiž {prestige})*",
|
||||
"prestige_entry": "{prefix} {name} - Prestiiž **{prestige}** · {pp} PP",
|
||||
"wagered_entry": "{prefix} {name} - {wagered} panustatud",
|
||||
"fish_entry": "{prefix} {name} - {caught} kala",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /request UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUEST_UI: dict[str, str] = {
|
||||
"modal_title": "Rahasta taotlust",
|
||||
"modal_label": "Kui palju soovid panustada?",
|
||||
"btn_fund": "Rahasta 💸",
|
||||
"btn_funded": "Rahastatud ✅",
|
||||
"btn_fund_remaining": "Rahasta 💸 ({remaining}⬡ jäänud)",
|
||||
"funded_line": "✅ **{name}** panustas {amount}",
|
||||
"funded_full": "\n🎯 Taotlus täielikult täidetud!",
|
||||
"funded_partial": "\n💰 Jäänud: {remaining}",
|
||||
"audience_all": "Avatud kõigile rahastajatele 🌍",
|
||||
"audience_targeted": "Suunatud: **{name}**",
|
||||
"desc": "**{requester}** palub {amount}\n\n📋 **Põhjus:** {reason}\n\n{audience}",
|
||||
"footer": "Taotlus aegub 5 minuti pärast",
|
||||
}
|
||||
86
strings/fishing.py
Normal file
86
strings/fishing.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Fishing minigame: fish catalogue, rarities, and /fish UI.
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'FISH_NAMES',
|
||||
'FISH_RARITY_NAMES',
|
||||
'FISH_RARITY_EMOJI',
|
||||
'FISH_JUNK_LINES',
|
||||
'FISH_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fishing system strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FISH_NAMES: dict[str, str] = {
|
||||
"sarj": "Särg",
|
||||
"ahven": "Ahven",
|
||||
"koger": "Koger",
|
||||
"viidikas": "Viidikas",
|
||||
"latikas": "Latikas",
|
||||
"karpkala": "Karpkala",
|
||||
"linask": "Linask",
|
||||
"haug": "Haug",
|
||||
"angerjas": "Angerjas",
|
||||
"siig": "Siig",
|
||||
"forell": "Forell",
|
||||
"koha": "Koha",
|
||||
"tougjas": "Tõugjas",
|
||||
"lohe": "Lõhe",
|
||||
"vimb": "Vimb",
|
||||
}
|
||||
FISH_RARITY_NAMES: dict[str, str] = {
|
||||
"common": "Tavaline",
|
||||
"uncommon": "Ebatavaline",
|
||||
"rare": "Haruldane",
|
||||
"epic": "Eepiline",
|
||||
"legendary": "Legendaarne",
|
||||
}
|
||||
FISH_RARITY_EMOJI: dict[str, str] = {
|
||||
"common": "🐟",
|
||||
"uncommon": "🐠",
|
||||
"rare": "🎣",
|
||||
"epic": "⭐",
|
||||
"legendary": "🌟",
|
||||
}
|
||||
FISH_JUNK_LINES: list[str] = [
|
||||
"Sa saad... **vana saabas**. Klassika.",
|
||||
"Õnnitlused, leidsid **kasutatud autorehvi**. Keskkond tänab sind... mitte.",
|
||||
"Taas üks **klaaspudel** rohkem jões.",
|
||||
"**Vana poes käimise kott**! Hoidis aega hästi.",
|
||||
"**Roostes konserv** - ilma sildita. Parem mitte teada, mis sees on.",
|
||||
"**Ummistunud drenaažitoru**. Keegi oli hoolimatu.",
|
||||
"**Tühi rahakott**. Kellegi päev läks halvemaks kui sinu oma.",
|
||||
"**Vana CD-plaat** - Evanescence, 2003. Heas seisukorras.",
|
||||
"Sa said **kivikese**. Ilus kivikene. Aga siiski kivikene.",
|
||||
"**Kaotsi läinud droon**. GPS ei tööta, aku tühi.",
|
||||
]
|
||||
FISH_UI: dict[str, str] = {
|
||||
"btn_wait": "🎣 Oota näkkamist...",
|
||||
"btn_bite": "🐟 TÕMBA!",
|
||||
"btn_sell": "💰 Müü",
|
||||
"btn_keep": "🎒 Hoia",
|
||||
"cast_desc": "Viskad õnge vette. Oota, kuni kala näkkab...\n\n-# Vajuta nuppu, kui kala näkkab!",
|
||||
"bite_desc": "**KALA NÄKKAB!** Tõmba kiiresti! ⚡\n\n-# Sul on 2 sekundit!",
|
||||
"escape_desc": "Liiga hilja - kala lipsas minema. Proovi järgmine kord kiiremini!",
|
||||
"junk_desc": "{text}\n\n-# Saldo: {balance}",
|
||||
"catch_desc": "**{name}** · {weight}g · +{exp} EXP\n-# Kas müüd kohe ({value}) või hoiad inventaris?",
|
||||
"catch_sold": "**{name}** · {weight}g\n\n+{coins} · +{exp} EXP\nSaldo: {balance}",
|
||||
"catch_kept": "**{name}** · {weight}g lisatud inventarisse. *(+{exp} EXP)*",
|
||||
"new_fish": "\n✨ **Uus kala kalakogusse lisatud!**",
|
||||
"too_early": "❌ Kala pole veel näkkanud! Oota...",
|
||||
"book_caught": "Püütud kalaliike: **{caught}/{total}**",
|
||||
"book_yes": "✅ {emoji} **{name}** *({rarity})* · {count}×{inv}",
|
||||
"book_inv": " *(inventaris: {n})*",
|
||||
"book_no": "❓ **???** *({rarity})*",
|
||||
"book_footer": "Lehekülg {page}/{total_pages} · {caught}/{total} liiki",
|
||||
"book_empty": "Sa pole veel ühtegi kala püüdnud! Kasuta `/fish`.",
|
||||
"inv_empty": "Sinu kalainventaar on tühi! Kasuta `/fish` kala püüdmiseks.",
|
||||
"inv_header": "Sul on **{count}** kala inventaris *(kokku väärt {total_value})*",
|
||||
"inv_entry": "{emoji} **{name}** · {weight}g · {value}",
|
||||
"inv_sold_all": "Müüsid **{count}** kala kokku {coins} eest!\nSaldo: {balance}",
|
||||
"inv_none": "Inventaaris pole midagi müüa.",
|
||||
}
|
||||
307
strings/games.py
Normal file
307
strings/games.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""Minigames & gambling: slots, roulette, RPS, blackjack, heist, jailbreak.
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
from core.emoji import EMOJI as E
|
||||
|
||||
__all__ = [
|
||||
'SLOTS_TIERS',
|
||||
'ROULETTE',
|
||||
'HEIST_STORY',
|
||||
'HEIST_UI',
|
||||
'BJ',
|
||||
'JAILBREAK_UI',
|
||||
'SLOTS_UI',
|
||||
'RPS_UI',
|
||||
'RPS_CHOICES',
|
||||
'BJ_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /slots outcome strings (title, colour)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SLOTS_TIERS: dict[str, tuple[str, int]] = {
|
||||
"jackpot": (f"{E['TipiFIRE']} JACKPOT!!!", 0xF4C430),
|
||||
"triple": ("🎰 Kolmik!", 0x57F287),
|
||||
"pair": ("🎰 Paar", 0x99AAB5),
|
||||
"miss": (f"{E['TipICRY']} Ei õnnestunud", 0xED4245),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /roulette outcome strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROULETTE: dict = {
|
||||
"emoji": {"punane": "🔴", "must": "⚫", "roheline": "🟢"},
|
||||
"genitive": {"punane": "punase", "must": "musta", "roheline": "rohelise"},
|
||||
"win_title": "{emoji} Võitsid!",
|
||||
"lose_title": "{emoji} Kaotasid!",
|
||||
"win_desc": "Ratas peatus **{genitive}** peal!{mult}\n+{change}\nSaldo: {balance}",
|
||||
"lose_desc": "Ratas peatus **{genitive}** peal.\n-{change}\nSaldo: {balance}",
|
||||
"spin_title": "🎰 Ratas keerleb...",
|
||||
"spin_stop": "🎰 Ratas peatub...",
|
||||
"spin_strip": "{s0} {s1} {s2} {s3} {s4}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /heist narrative story lines
|
||||
# Placeholders: {leader}, {member}, {names}, {vehicle}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HEIST_STORY: dict = {
|
||||
"vehicles": [
|
||||
"Škoda",
|
||||
"BMW",
|
||||
"Lada",
|
||||
"Mazda",
|
||||
],
|
||||
"arrival": [
|
||||
"{leader} keerab nurga taga {vehicle} mootori kinni. Maskid ette. Keegi ei räägi.",
|
||||
"{vehicle} peatub ühe kvartali kaugusel sihtmärgist. {leader}: *\"Pank on seal. Tegutseme plaani järgi.\"*",
|
||||
"Meeskond astub {vehicle}st välja. {leader} kuulab kõrvamonitori. *\"Kaks valvurit, üks pimeala. Liigume nüüd.\"*",
|
||||
"{leader} jälgib {vehicle}st sissepääsu. *\"Valvurite vahetus 40 sekundi pärast. See on meie ajaaken.\"*",
|
||||
"Kell 3 öösel. Tänav on tühi. {vehicle} seisab väljas tühikäigul. {leader} tõmbab maski ette.",
|
||||
"{vehicle} veereb aeglaselt vaiksesse pimedasse vahetänavasse. {leader}: *\"Viimane kontroll. Kõik valmis?\"*",
|
||||
"{leader} koputab armatuurlauale. *\"Kui midagi läheb valesti, me lahkume kohe.\"* Vaikus vastuseks.",
|
||||
"Tuul sahiseb mööda tühja tänavat. {vehicle} uksed avanevad korraga.",
|
||||
"{member} kontrollib relva. {leader} vaatab kella. *\"Me oleme graafikus.\"*",
|
||||
"{vehicle} tuled kustuvad. Linn jääb vaikseks. Nad liiguvad.",
|
||||
],
|
||||
"entry_sneaky": [
|
||||
"{member} tõmbab kloonitud kaarti külgukse juures. Lukk klõpsab hääleta lahti.",
|
||||
"Meeskond libiseb sisse laadimisestakaadi kaudu, riietatud öisteks koristajateks.",
|
||||
"{leader} on kaks nädalat valvurite graafikut pähe õppinud. Röövlid kõnnivad sisse vahetuse ajal.",
|
||||
"Võltsitud alltöövõtja kaardi abil saavad nad fuajeest läbi ilma lisaküsimuseta.",
|
||||
"{member} lülitab välikaamerad sülearvutist välja. {leader} kõnnib sisse, nagu see oleks tema oma maja.",
|
||||
"{member} kasutab signaaliblokeerijat. Häired ei jõua kunagi süsteemi.",
|
||||
"{leader} avab ventilatsiooniluugi. *\"Läheme ülevalt.\"*",
|
||||
"Turvamees haigutab. Sekund hiljem on ta seotud ja vaikselt nurka lohistatud.",
|
||||
"{member} süstib lukku mikrokaamera. Mehhanism kaardistatakse sekunditega.",
|
||||
],
|
||||
"entry_loud": [
|
||||
"{leader} lööb esiuksed lahti. *\"KÕIK PÕRANDALE - KOHE!\"*",
|
||||
"Esmalt suitsugranaadid. Selleks ajaks, kui suits vaibub, on meeskond juba sees.",
|
||||
"{leader} tulistab ühe lasu lakke. Haudvaikus. *\"Oleme siin seifi pärast! Kui teete koostööd, siis pääsete elusana.\"*",
|
||||
"*\"Kui keegi ei liigu, ei saa keegi ka viga.\"* {leader} omab ruumi täielikku tähelepanu.",
|
||||
"{member} lükkab kilbist elektri välja. Pimeduses juhib {leader} meeskonna mälu järgi edasi.",
|
||||
"*\"See on rööv! Kõik pikali!\"* {leader} hääl kajab läbi saali.",
|
||||
"Alarm hakkab ulguma juba enne, kui nad täielikult sisse jõuavad.",
|
||||
"Klaas puruneb. Inimesed karjuvad. Täielik kaos.",
|
||||
],
|
||||
"inside": [
|
||||
"{names} liiguvad kiiresti läbi fuajee, sidudes turvamehi postide külge kinni.",
|
||||
"{member} katab väljapääsud. {leader} suundub otse seifi poole.",
|
||||
"Üks kassapidajatest proovib käivitada vaikset häiret, kuid {member} märkab ta nihelemist ja peatab ta hoiatuslasuga.",
|
||||
"{leader} hoiab töötajad rahulikuna samal ajal, kui teised suunduvad alumisele korrusele.",
|
||||
"Turvakaamerad on tsüklil. {names} on korrusel üksi.",
|
||||
"{member} kontrollib kellaaega. *\"Me oleme 30 sekundit ees.\"*",
|
||||
"{leader} annab käemärgi. Meeskond jaguneb ilma sõnadeta.",
|
||||
"Koridor on tühi. Liiga tühi. {leader} peatub hetkeks.",
|
||||
"{names} liiguvad trepist alla, sammud summutatud.",
|
||||
"Üks uks on lukus. {member} avab selle sekundiga.",
|
||||
],
|
||||
"vault": [
|
||||
"Neljanda taseme ajalukuga seif. {member} tõmbab puuri välja. *\"Anna mulle kolm minutit.\"*",
|
||||
"{leader} vaatab seifi ust. *\"Dünamiit.\"* Keegi ei vaidle.",
|
||||
"{member} ühendub seifi juhtpaneeliga. Vana tarkvara. Ülevõtmine võtab 90 sekundit.",
|
||||
"Seifil on käsikombinatsiooni lukk. Hea, et {leader} veetis kuu aega juhatajaga, teenides tema usaldust.",
|
||||
"{member} paigutab lõhkeained. Meeskond astub tagasi. Üks kontrollitud plahvatus.",
|
||||
"{leader} teeb magnetlukule tühistuse. Nagu õpikust võetud. Mehhanism annab järgi.",
|
||||
"{member} higistab. *\"See pole standardlukk... anna mulle aega.\"*",
|
||||
"{leader} kuulab vastu seifi ust. *\"Sees on liikumisandur.\"*",
|
||||
"{member} lõikab läbi metallkihi nagu võid.",
|
||||
"Ajurünnak. {leader} meenutab skeemi ja leiab nõrga koha.",
|
||||
"{member} ühendab juhtmed ümber. Säde. Vaikus. Lukk avaneb.",
|
||||
],
|
||||
"vault_open": [
|
||||
"Uks avaneb. Kuhi-kuhja järel raha, põrandast laeni. Täpselt nagu plaanitud.",
|
||||
"*\"...jackpot.\"* {leader} piilub seifi sisse. Meeskond seisab hetkeks vaikuses.",
|
||||
"{member} hakkab kotte täis laduma. {leader} piilub juba väljapääsu poole.",
|
||||
"Puhas. Kiire. {names} on seifis ja täidavad kotte enne, kui tolm settib.",
|
||||
"Seif on lahti. {leader} võtab hinge. *\"Okei. Kottidesse ja liikuma.\"*",
|
||||
"{leader} naeratab esimest korda. *\"See oli seda väärt.\"*",
|
||||
"Raha lõhn täidab õhu. {member}: *\"Võtame kõik.\"*",
|
||||
"{names} töötavad vaikides. Iga liigutus loeb.",
|
||||
"Kotid täituvad kiiremini kui oodatud.",
|
||||
"{leader} pilk muutub tõsiseks. *\"Aeg otsas. Liigume.\"*",
|
||||
],
|
||||
"police_inbound": [
|
||||
"Raadio krõbiseb. *\"Kõik üksused, relvastatud rööv Keskpangas-\"*",
|
||||
"Väljas on kuulda eemalt ulguvaid sireene. {leader}: *\"Meil on umbes neli minutit. Liikuma.\"*",
|
||||
"Punased ja sinised tuled vilguvad ülemistest akendest läbi. Varualarm käivitati.",
|
||||
"Politsei helikopter skaneerib piirkonda prožektoriga. Aken sulgub kiiresti.",
|
||||
"{member} kontrollib politsei skännerit. *\"Nad teavad. Kolm üksust, kaks minutit eemal.\"*",
|
||||
"{member} kuulab raadiosidet. *\"Nad sulgevad kvartaleid.\"*",
|
||||
"Sireenid lähenevad kiiremini kui plaanitud.",
|
||||
"Helikopteri valgus libiseb üle akna. Liiga lähedal.",
|
||||
],
|
||||
"getaway_success": [
|
||||
"Adrenaliin. {names} jooksevad {vehicle} juurde. Kõik sees. {leader} keerab mootori käima.",
|
||||
"{member} haarab roolist. {leader} on kõrval. *\"Lähme.\"* Rahulikult.",
|
||||
"{leader} libistab {vehicle} kõrvalteed pidi minema. Sireen kaugel taga. Nad on kiiremad.",
|
||||
"Rohelised tuled. {vehicle} sõidab vaikselt mööda parklas ootavast patrullautost. Keegi ei märka.",
|
||||
"{member} näitab teed. {leader} sõidab vaikselt läbi tagakvartali. Käed lõdvad.",
|
||||
"{vehicle} rehvid vilisevad, kui nad pööravad kitsasse tänavasse.",
|
||||
"{leader} sõidab ilma tuledeta. Ainult mälu juhib teda.",
|
||||
"{member} vaatab kaarti. *\"Vasak, siis kohe parem!\"*",
|
||||
"Mootor möirgab. Nad kaovad öösse.",
|
||||
"{vehicle} libiseb läbi viimase rohelise tule.",
|
||||
],
|
||||
"getaway_fail": [
|
||||
"{names} jooksevad {vehicle} juurde. Sireenid igalt poolt.",
|
||||
"{member} haarab roolist. {leader} on kõrval. *\"Mine! MINE!\"*",
|
||||
"{vehicle} kihutab tänavale, kuid helikopteri valgusvihk osutab neile. Kõik on nähtaval.",
|
||||
"Kolm patrullautot jõuavad nende taha. {member} vaatab üle õla. *\"Nad on meil kannul.\"*",
|
||||
"Raadio krõbiseb. *\"Sihtmärk kinnitatud. Blokeeri Liivalaia tänav.\"*",
|
||||
"{vehicle} ei käivitu. {member}: *\"Päriselt ka või?!\"*",
|
||||
"{member} komistab teel autoni. Tal oli pael lahti tulnud",
|
||||
"{leader} avastab, et autot pole kuskil näha. See pukseeriti ära."
|
||||
],
|
||||
"escape_success": [
|
||||
"{names} murravad läbi perimeetri enne väravate sulgemist. Puhas põgenemine.",
|
||||
"{leader} oli planeerinud kolm väljumisteed. Vaja läks ühte. {vehicle} kaob linna.",
|
||||
"Kahe kvartali kaugusel asuv peibutushäire tõmbab üksused eemale. {names} on kadunud enne, kui keegi seifi kontrollib.",
|
||||
"Naelribad? Juba eemaldatud. Teeblokk? Vale tänav. {leader} mõtles kõigele.",
|
||||
"{vehicle} sõidab maanteele nagu poleks midagi juhtunud. {leader} hingab esimest korda tunnis välja.",
|
||||
"*\"Oleme vabad.\"* {member} piilub kardinate tagant välja. Tühi tänav. Nad tegid ära.",
|
||||
"{names} kaovad sügavale linna nagu neid poleks kunagi olnud.",
|
||||
"Raadio jääb vaikseks. Keegi ei jälita enam.",
|
||||
"{vehicle} jäetakse maha. Uus plaan aktiveerub.",
|
||||
"{leader}: *\"See oli liiga lihtne...\"*",
|
||||
],
|
||||
"escape_fail": [
|
||||
"Patrullauto lõikab {vehicle} teekonna sillal ära. Mõlemalt poolt ümbritsetud. Ongi läbi.",
|
||||
"{member} kukutab koti. Kolm sekundit kõhklust - ja uksed on ümber piiratud.",
|
||||
"Helikopter jälgib neid kuni pelgupaigani. Kõik väljapääsud on kaetud.",
|
||||
"{leader} arvutab võimalusi. *\"Väljapääsu pole.\"* Käed lähevad üles.",
|
||||
"Naelribad lõhuvad {vehicle} rehvid. Meeskond libiseb liikluse keskele seisma.",
|
||||
"{vehicle} põrkub vastu teetõket. Mootor sureb. Igast suunast tuled.",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /heist UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HEIST_UI: dict[str, str] = {
|
||||
"names_duo": "**{a}** ja **{b}**",
|
||||
"names_sep": " & ",
|
||||
"names_crew": "**{leader}** ja meeskond",
|
||||
"btn_join": "Ühine röövimisega 🔫",
|
||||
"btn_start": "Alusta kohe ▶",
|
||||
"already_joined": "Sa oled juba sees!",
|
||||
"only_organizer": "Ainult heisti algataja saab heisti alustada.",
|
||||
"lobby_desc": "**Osalejad ({n}/{max}):**\n{names}\n\nÕnnestumise tõenäosus: **{chance}%**\n\n*Ühinemisaken sulgub <t:{ts}:R>*",
|
||||
"cancel_desc": "Ei piisanud osalejaid (vajad vähemalt **{min}**). Heist tühistati.",
|
||||
"started_title": "🔫 Grupirööv käib...",
|
||||
"started_desc": "**{n}** osalejat alustasid heisti. Jälgi allpool!",
|
||||
"story_title": "🔫 Grupirööv",
|
||||
"win_desc": "**Osalejad:**\n{names}\n\nSaak jagati võrdselt.\n**Igaüks sai: +{payout}**",
|
||||
"fail_desc": "**Osalejad:**\n{names}\n\nKõik osalejad saavad **1,5 tundi vangi** + trahv ~15% saldost.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blackjack in-game strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BJ: dict[str, str] = {
|
||||
"dealing": "*Jagame kaardid...*",
|
||||
"result_field": "Tulemus",
|
||||
"push_result": "±0 (panus tagasi)",
|
||||
"doubled_label": "💰 *Topeltpanus: {total}*",
|
||||
"btn_hit": "🃏 Võta kaart",
|
||||
"btn_stand": "✋ Seisa",
|
||||
"btn_double": "💰 Kahekordista (+{bet}⬡)",
|
||||
"btn_split": "✂️ Split (+{bet}⬡)",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /jailbreak UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JAILBREAK_UI: dict[str, str] = {
|
||||
"btn_roll": "🎲 Viska täringud ({try_}/{max})",
|
||||
"rolling_desc": f"{E['TipiDICE']} *Täringud lendavad...*",
|
||||
"free_desc": "{d1} {d2}\n\n✅ Viskasid duubli - pääsesid vanglast!",
|
||||
"miss_desc": "{d1} {d2}\n\n{left} katset jäänud. Proovi uuesti!",
|
||||
"intro_desc": "Oled vangis kuni {ts}.\n\nViska täringuid ja proovi **duublit** saada - siis pääsed tasuta vabaks!\nSul on **{tries} katset**. Ebaõnnestumisel saad valida: maksa kautsjon **(20–30% saldost, min 350 ⬡)** või jää vanglasse kuni aja lõpuni.",
|
||||
"already_bail": "Oled juba täringuid visanud. Ainuke väljapääs on kautsjon.\n\n💰 **Kautsjon: {min} – {max}** (20–30% saldost)\nSaldo: {bal} Väljas: {ts}",
|
||||
"already_broke": "Oled juba täringuid visanud. Sul pole piisavalt raha kautsjoni maksmiseks.\n\n💰 Kautsjoni miinimum: **{min}** - sul on ainult {bal}\nVäljas: {ts}",
|
||||
"bail_btn": "💸 Maksa kautsjon",
|
||||
"bail_broke_desc": "❌ Sul pole piisavalt raha (min {min}).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
|
||||
"bail_paid_desc": "✅ Kautsjon makstud: **{fine}**\nOled vaba! Saldo: {balance}",
|
||||
"bail_already_free": "✅ Oled juba vaba - kautsjonit ei võetud. Saldo: {balance}",
|
||||
"fail_broke_desc": "{d1} {d2} - ei olnud duubel\n\n❌ Sul pole kautsjoni maksmiseks piisavalt raha (min 350 ⬡).\nJääd vanglasse kuni aja lõpuni! Saldo: {balance}",
|
||||
"fail_bail_desc": "{d1} {d2} - ei olnud duubel\n\nKautsjon makstud: **{fine}**\nSaldo: {balance}",
|
||||
"fail_bail_offer": "{d1} {d2} - ei olnud duubel\n\n💰 **Kautsjon: {min} - {max}** (20-30% saldost)\nSaldo: {bal}\n\nSaad maksta kautsjoni või jääda vanglasse kuni aja lõpuni.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /slots additional UI strings (titles/footers per outcome)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SLOTS_UI: dict[str, str] = {
|
||||
"playing": "🎰 Mängimas...",
|
||||
"jackpot_footer": E["TipiKARIKAS"] + " Kolm karikat! +{change}",
|
||||
"triple_footer": "✅ Kolm ühesugust! +{change}",
|
||||
"pair_footer": "Kaks ühesugust! +{change}",
|
||||
"miss_footer": "-{amount}",
|
||||
"balance_line": "\nSaldo: {balance}",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /rps UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RPS_UI: dict[str, str] = {
|
||||
"vs_bot_desc": "Tee oma valik! ⬇️",
|
||||
"vs_bot_bet": "\nPanus: {bet}",
|
||||
"result_tie": "🤝 Viik!",
|
||||
"result_win": "🎉 Sina võitsid!",
|
||||
"result_lose": "🤖 Bot võitis!",
|
||||
"result_desc": "Sina: {player_pick} {player_name}\nBot: {bot_pick} {bot_name}\n\n**{result}**{bet_line}",
|
||||
"bet_win": "\n+{amount} · Saldo: {balance}",
|
||||
"bet_lose": "\n-{amount} · Saldo: {balance}",
|
||||
"bet_tie": "\nViik - panus tagasi · Saldo: {balance}",
|
||||
"challenge_desc": "{challenger} kutsub välja {opponent}{bet}\n\n{opponent}, kas võtad vastu? ⬇️",
|
||||
"challenge_bet": " · Panus: {bet}",
|
||||
"challenge_footer": "Väljakutse aegub 60 sekundi pärast",
|
||||
"duel_active_desc": "{a} vs {b}{bet}\n\nMõlemad mängijad valivad DM-is. ⏳",
|
||||
"duel_active_bet": " · Panus: {bet}",
|
||||
"duel_dm": "⚔️ **KPK vs {opponent}**{bet}\nTee oma valik! ⬇️",
|
||||
"duel_dm_bet": "\nPanus: {bet}",
|
||||
"duel_waiting": "Valisid {choice} **{name}**. Ootame vastase valikut... ⏳",
|
||||
"duel_result_a": "**⚔️ KPK tulemus vs {opponent}**\nSinu valik: {pick_a} {name_a}\nVastase valik: {pick_b} {name_b}\n\n**{result}**{bet_line}\nSaldo: {balance}",
|
||||
"duel_verdict_win": "🏆 **{name}** võitis!",
|
||||
"duel_verdict_tie": "🤝 Viik!",
|
||||
"duel_broke": "\n⚠️ Makse ebaõnnestus (ebapiisav saldo)",
|
||||
"duel_expire_dm": "⏰ KPK duell aegus - keegi ei teinud valikut õigeaegselt.",
|
||||
"duel_expire_desc": "{a} vs {b}\n\nMäng aegus - valik jäi tegemata.",
|
||||
"duel_dm_fail": "Ei saanud DM saata: **{names}**. Kontrolli privaatsussätteid.",
|
||||
"duel_decline": "**{name}** keeldus väljakutsest.",
|
||||
"duel_no_answer": "**{name}** ei vastanud väljakutsele.",
|
||||
"duel_insufficient": "{mention}-l pole piisavalt TipiCOINi panuseks.",
|
||||
"duel_done_desc": "{a} {pick_a} vs {pick_b} {b}\n\n**{verdict}**\n\n{name_a}: {bal_a}\n{name_b}: {bal_b}",
|
||||
"server_only": "Seda käsku saab kasutada ainult serveris.",
|
||||
"btn_accept": "Võta vastu ✅",
|
||||
"btn_decline": "Keeldu ❌",
|
||||
"btn_rock": "🪨 Kivi",
|
||||
"btn_paper": "📄 Paber",
|
||||
"btn_scissors": "✂️ Käärid",
|
||||
}
|
||||
RPS_CHOICES: dict[str, str] = {
|
||||
"🪨": "Kivi",
|
||||
"📄": "Paber",
|
||||
"✂️": "Käärid",
|
||||
}
|
||||
BJ_UI: dict[str, str] = {
|
||||
"dealer": "Dealer",
|
||||
"player": "Sina",
|
||||
"hand_n": "Käsi {n}",
|
||||
"hand_active": "▶ **Käsi {n}**",
|
||||
"hand_pending": "Käsi {n} *...*",
|
||||
"bust": " 💥",
|
||||
"balance_line": " · Saldo: {balance}",
|
||||
}
|
||||
119
strings/member.py
Normal file
119
strings/member.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Member sync, /check, birthdays, channel + economy-setup admin UI.
|
||||
|
||||
Auto-split from the original strings.py; edit strings here.
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
'MEMBER_UI',
|
||||
'MEMBER_FIELDS',
|
||||
'ECONOMYSETUP_UI',
|
||||
'CHANNEL_UI',
|
||||
'BIRTHDAY_UI',
|
||||
'BIRTHDAY_MONTHS',
|
||||
'CHECK_UI',
|
||||
'TEAMSYNC_UI',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /allowchannel /denychannel /channels UI strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEMBER_UI: dict[str, str] = {
|
||||
"title": "👤 {name}",
|
||||
"age_field": "Vanus",
|
||||
"age_val": "{age}a",
|
||||
}
|
||||
MEMBER_FIELDS: list[tuple[str, str]] = [
|
||||
("Nimi", "Nimi"),
|
||||
("Organisatsioon", "Organisatsioon"),
|
||||
("Valdkond", "Valdkond"),
|
||||
("Roll", "Roll"),
|
||||
("Sünnipäev", "Sünnipäev"),
|
||||
("Meil", "Meil"),
|
||||
("Telefon", "Telefon"),
|
||||
("Discord", "Discord"),
|
||||
("User ID", "Kasutaja ID"),
|
||||
("Discordis synced?", "Sünkroniseeritud"),
|
||||
("Groupi lisatud?", "Grupis"),
|
||||
]
|
||||
ECONOMYSETUP_UI: dict[str, str] = {
|
||||
"created_field": "✅ Loodud",
|
||||
"existing_field": "♻️ Juba olemas",
|
||||
"footer": "Rollid on nüüd boti rolli all õiges järjekorras.",
|
||||
}
|
||||
CHANNEL_UI: dict[str, str] = {
|
||||
"already_allowed": "ℹ️ {channel} on juba lubatud kanalite nimekirjas.",
|
||||
"added": "✅ {channel} lisatud. Bot vastab käskudele nüüd ainult lubatud kanalites.",
|
||||
"not_in_list": "ℹ️ {channel} pole lubatud kanalite nimekirjas.",
|
||||
"removed": "✅ {channel} eemaldatud lubatud kanalite nimekirjast.",
|
||||
"removed_last": "✅ {channel} eemaldatud. Lubatud kanalite nimekiri on nüüd tühi - bot vastab kõikides kanalites.",
|
||||
"list_title": "📋 Lubatud kanalid",
|
||||
"list_empty": "Lubatud kanalite nimekiri on **tühi** - bot vastab käskudele kõikides kanalites.\n\nKasuta `/allowchannel`, et piirata kindlatele kanalitele.",
|
||||
"list_filled": "Bot vastab käskudele ainult nendes kanalites:\n\n{lines}\n\n-# Kasuta `/allowchannel` / `/denychannel` nimekirja muutmiseks.",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /birthdays UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BIRTHDAY_UI: dict[str, str] = {
|
||||
"no_entries": "Sellel kuul sünnipäevi ei ole.",
|
||||
"today": "täna 🎉",
|
||||
"tomorrow": "homme",
|
||||
"in_days": "{days}p pärast",
|
||||
"footer": "Leht {month}/12 · {month_name}",
|
||||
}
|
||||
BIRTHDAY_MONTHS: list[str] = [
|
||||
"Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni",
|
||||
"Juuli", "August", "September", "Oktoober", "November", "Detsember",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /check summary strings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHECK_UI: dict[str, str] = {
|
||||
"sheet_stats_header": "📊 **Tabeli statistika** - {total} liiget",
|
||||
"stat_ok": "✅ **{label}**: kõik täidetud",
|
||||
"stat_warn": "⚠️ **{label}** puudub ({count}): {names}{more}",
|
||||
"stat_more": " (+{count} veel)",
|
||||
"stat_uid": "Kasutaja ID",
|
||||
"stat_discord": "Discordi kasutajanimi",
|
||||
"stat_bday": "Sünnipäev",
|
||||
"no_name": "(no name)",
|
||||
"done": "**Kontroll lõpetatud!**",
|
||||
"already_ok": "✅ Juba korras: {count}",
|
||||
"fixed": "🔧 Parandatud: {count}",
|
||||
"not_found": "❓ Pole tabelis: {count}",
|
||||
"bday_pings": "🎂 Sünnipäeva teavitused: {count}",
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"details_header": "**Üksikasjad:**",
|
||||
"details_more": "... ja {count} rohkem",
|
||||
"detail_error": "⚠️ {error}",
|
||||
"detail_nickname": "hüüdnimi",
|
||||
"detail_roles_added": "+rollid: {roles}",
|
||||
"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}",
|
||||
"positioned": "📍 Eraldaja alla paigutatud: {count}",
|
||||
"divider_assigned": "🏷️ Mängurolle (eraldaja) antud: {count}",
|
||||
"divider_removed": "➖ Mängurolle (eraldaja) eemaldatud: {count}",
|
||||
"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
|
||||
101
tests/test_fienta.py
Normal file
101
tests/test_fienta.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for the Fienta ticket parsing that feeds team-role sync.
|
||||
|
||||
Covers the risky bit: turning raw Fienta ticket JSON into a reliable
|
||||
{discord identity -> team} + {team -> game} mapping, including which ticket
|
||||
types count as team members and how the game is detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import config # noqa: E402
|
||||
from core import fienta # noqa: E402
|
||||
|
||||
|
||||
def _ticket(ttype: str, *, team="", discord="", discord_id="", nick=""):
|
||||
return {
|
||||
"order_id": 1,
|
||||
"rows": [{
|
||||
"ticket_type": {"title": ttype},
|
||||
"attendee": {
|
||||
fienta._F_TEAM_NAME: team,
|
||||
fienta._F_DISCORD_USERNAME: discord,
|
||||
fienta._F_DISCORD_USERID: discord_id,
|
||||
fienta._F_DISCORD_USERNAME.replace("discord", "x"): "",
|
||||
"nickname_134815": nick,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
CS2 = "Counter-Strike 2 Tournament - competitor ticket"
|
||||
LOL = "League of Legends Tournament - competitor ticket"
|
||||
|
||||
|
||||
def test_parse_maps_username_and_userid_to_team():
|
||||
fienta.parse_tickets([
|
||||
_ticket(CS2, team="KONE", discord="ar7enchik", discord_id="123"),
|
||||
])
|
||||
assert fienta.get_team_for_username("ar7enchik") == "KONE"
|
||||
assert fienta.get_team_for_username("AR7ENCHIK") == "KONE" # case-insensitive
|
||||
assert fienta.get_team_for_userid(123) == "KONE"
|
||||
assert fienta.get_team_game("KONE") == "CS2"
|
||||
|
||||
|
||||
def test_parse_detects_game_from_ticket_type():
|
||||
fienta.parse_tickets([
|
||||
_ticket(CS2, team="KONE", discord="a"),
|
||||
_ticket(LOL, team="Ööbik", discord="b"),
|
||||
])
|
||||
assert fienta.get_team_game("KONE") == "CS2"
|
||||
assert fienta.get_team_game("Ööbik") == "LoL"
|
||||
|
||||
|
||||
def test_parse_excludes_non_player_ticket_types():
|
||||
fienta.parse_tickets([
|
||||
_ticket("Visitor's Ticket", team="", discord=""),
|
||||
_ticket("Early Bird - visitor ticket", team="X", discord="ghost"),
|
||||
_ticket("Counter-Strike 2 Tournament Waiting List", team="WL", discord="waiter"),
|
||||
_ticket("LAN area - Access Ticket", team="", discord=""),
|
||||
_ticket(CS2, team="KONE", discord="real"),
|
||||
])
|
||||
assert fienta.all_team_names() == {"KONE"}
|
||||
assert fienta.get_team_for_username("ghost") is None
|
||||
assert fienta.get_team_for_username("waiter") is None
|
||||
assert fienta.get_team_for_username("real") == "KONE"
|
||||
|
||||
|
||||
def test_parse_includes_coach_and_substitute():
|
||||
fienta.parse_tickets([
|
||||
_ticket("Counter-Strike 2 Coach/Manager - competitor ticket", team="KONE", discord="coach"),
|
||||
_ticket("CS2 Substitute Player - competitor ticket", team="KONE", discord="sub"),
|
||||
])
|
||||
assert fienta.get_team_for_username("coach") == "KONE"
|
||||
assert fienta.get_team_for_username("sub") == "KONE"
|
||||
|
||||
|
||||
def test_parse_skips_tickets_without_team_or_discord():
|
||||
fienta.parse_tickets([
|
||||
_ticket(CS2, team="", discord="noteam"), # no team -> skipped
|
||||
_ticket(CS2, team="KONE", discord=""), # team but no discord -> team known, no user
|
||||
])
|
||||
assert fienta.get_team_for_username("noteam") is None
|
||||
assert "KONE" in fienta.all_team_names()
|
||||
|
||||
|
||||
def test_get_team_dividers_maps_via_game(monkeypatch):
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
|
||||
fienta.parse_tickets([
|
||||
_ticket(CS2, team="KONE", discord="a"),
|
||||
_ticket(LOL, team="Ööbik", discord="b"),
|
||||
])
|
||||
assert fienta.get_team_dividers() == {"KONE": 100, "Ööbik": 200}
|
||||
|
||||
|
||||
def test_strips_leading_at_from_discord_username():
|
||||
fienta.parse_tickets([_ticket(CS2, team="KONE", discord="@handle")])
|
||||
assert fienta.get_team_for_username("handle") == "KONE"
|
||||
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__)
|
||||
383
tests/test_team_sync.py
Normal file
383
tests/test_team_sync.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""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))
|
||||
|
||||
import config # noqa: E402
|
||||
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"]]) == {}
|
||||
|
||||
|
||||
# --- parse_team_sections (which game/year a block of teams came from) ------
|
||||
|
||||
def test_parse_team_sections_tags_each_block_with_its_title():
|
||||
sections = sheets.parse_team_sections(SHEET_ROWS)
|
||||
assert [(s.title, sorted(s.rosters)) for s in sections] == [
|
||||
("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]),
|
||||
("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]),
|
||||
]
|
||||
|
||||
|
||||
def test_parse_team_sections_title_does_not_leak_to_untitled_block():
|
||||
rows = [
|
||||
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2,
|
||||
["No", "Team Name", "Lineup (nickname)"],
|
||||
["1", "GENESIS", "kapa (EST)"],
|
||||
["", "", ""],
|
||||
["No", "Team Name", "Lineup (nickname)"], # second block, no title above it
|
||||
["1", "Nameless", "someone (EST)"],
|
||||
]
|
||||
titles = [s.title for s in sheets.parse_team_sections(rows)]
|
||||
assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""]
|
||||
|
||||
|
||||
# --- resolve_divider (section title -> configured divider role) ------------
|
||||
|
||||
DIVIDERS = {
|
||||
"cs2_2026": 1498736834656604251,
|
||||
"lol_2026": 1498736949706490017,
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_divider_matches_game_and_year():
|
||||
assert sheets.resolve_divider(
|
||||
"[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS
|
||||
) == 1498736834656604251
|
||||
assert sheets.resolve_divider(
|
||||
"[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS
|
||||
) == 1498736949706490017
|
||||
|
||||
|
||||
def test_resolve_divider_year_scoped_key_ignores_other_years():
|
||||
assert sheets.resolve_divider("TipiLAN 2025 CS2 Registration Log", DIVIDERS) is None
|
||||
|
||||
|
||||
def test_resolve_divider_prefers_the_most_specific_match():
|
||||
dividers = {"cs2": 111, "cs2_2026": 222}
|
||||
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222
|
||||
assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111
|
||||
|
||||
|
||||
def test_resolve_divider_no_config_or_no_title_is_none():
|
||||
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", {}) is None
|
||||
assert sheets.resolve_divider("", DIVIDERS) is None
|
||||
|
||||
|
||||
def test_title_survives_notice_rows_below_it():
|
||||
# Real sheets stack the game/year title above single-cell notice rows; the
|
||||
# title's keywords must survive so resolve_divider still matches (regression:
|
||||
# notice rows used to overwrite the title, yielding a None divider).
|
||||
rows = [
|
||||
["TipiLAN 2026 CS2 Registration Log"] + [""] * 4,
|
||||
["This log is updated automatically."] + [""] * 4,
|
||||
["If a team from the Top 32 withdraws, ..."] + [""] * 4,
|
||||
["No", "Team Name", "Lineup (nickname, citizenship)", "", ""],
|
||||
["1", "GENESIS", "kapa (EST)", "", ""],
|
||||
]
|
||||
section = sheets.parse_team_sections(rows)[0]
|
||||
assert "CS2" in section.title and "2026" in section.title
|
||||
assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251
|
||||
|
||||
|
||||
# --- plan_team_positions (pure role-ordering maths) ------------------------
|
||||
|
||||
def test_plan_moves_teams_directly_under_their_divider():
|
||||
# Ascending = bottom-up, so the Discord UI renders this list reversed.
|
||||
ordered = ["@everyone", "60hz", "999", "Raid", "CS2-2026", "LOL-2026", "Bot"]
|
||||
plan = member_sync.plan_team_positions(
|
||||
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
|
||||
)
|
||||
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
|
||||
# Teams end up just below the divider, alphabetical downwards in the UI.
|
||||
assert final == ["@everyone", "Raid", "999", "60hz", "CS2-2026", "LOL-2026", "Bot"]
|
||||
|
||||
|
||||
def test_plan_groups_each_divider_separately():
|
||||
ordered = ["@everyone", "Alpha", "Pushing 30s", "CS2-2026", "LOL-2026", "Bot"]
|
||||
plan = member_sync.plan_team_positions(
|
||||
ordered,
|
||||
{"Bot"},
|
||||
{"CS2-2026": ["Alpha"], "LOL-2026": ["Pushing 30s"]},
|
||||
)
|
||||
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
|
||||
assert final == ["@everyone", "Alpha", "CS2-2026", "Pushing 30s", "LOL-2026", "Bot"]
|
||||
|
||||
|
||||
def test_plan_is_a_noop_when_already_positioned():
|
||||
ordered = ["@everyone", "Raid", "999", "60hz", "CS2-2026", "Bot"]
|
||||
assert member_sync.plan_team_positions(
|
||||
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
|
||||
) == {}
|
||||
|
||||
|
||||
def test_plan_skips_missing_divider_and_leaves_its_teams_alone():
|
||||
ordered = ["@everyone", "60hz", "CS2-2026", "Bot"]
|
||||
plan = member_sync.plan_team_positions(
|
||||
ordered, {"Bot"}, {"LOL-2026": ["60hz"]} # divider not in the guild
|
||||
)
|
||||
assert plan == {}
|
||||
|
||||
|
||||
def test_plan_never_moves_managed_roles():
|
||||
ordered = ["@everyone", "60hz", "CS2-2026", "Nitro", "Bot"]
|
||||
plan = member_sync.plan_team_positions(
|
||||
ordered, {"Nitro", "Bot"}, {"CS2-2026": ["60hz", "Nitro"]}
|
||||
)
|
||||
assert "Nitro" not in plan and "Bot" not in plan
|
||||
|
||||
|
||||
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 get_role(self, rid):
|
||||
return next((r for r in self.roles if r.id == rid), None)
|
||||
|
||||
|
||||
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_grants_game_divider_role(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
cs2_div = FakeRole(100, "====== CS2 2026 ======")
|
||||
member = FakeMember(1, "kapa", roles=[])
|
||||
guild = FakeGuild([genesis, cs2_div])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.divider_added == "====== CS2 2026 ======"
|
||||
assert cs2_div in member.roles
|
||||
|
||||
|
||||
def test_sync_swaps_divider_role_on_game_switch(monkeypatch):
|
||||
genesis = FakeRole(3, "GENESIS")
|
||||
cs2_div = FakeRole(100, "CS2")
|
||||
lol_div = FakeRole(200, "LoL")
|
||||
member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team
|
||||
guild = FakeGuild([genesis, cs2_div, lol_div])
|
||||
|
||||
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
|
||||
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
|
||||
monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100})
|
||||
monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200})
|
||||
|
||||
result = run(member_sync.sync_team_role(member, guild))
|
||||
|
||||
assert result.divider_added == "CS2"
|
||||
assert result.divider_removed == ["LoL"]
|
||||
ids = {r.id for r in member.roles}
|
||||
assert 100 in ids and 200 not in ids
|
||||
|
||||
|
||||
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