7 Commits

Author SHA1 Message Date
Rene Arumetsa
bd3fb7d3b8 feat(teams): add opt-in /teamsync reposition to tidy existing team roles
Creation-time placement only positions NEW team roles. Team roles made before
divider placement existed (sitting at the bottom of the role list) stayed put.

Add `reposition_team_roles(guild)` and expose it via `/teamsync reposition:true`
(off by default, so the hourly task never reorders roles on its own). It moves
each EXISTING team role under its game's divider, sheet-driven:

- a role's game/divider comes from sheets.get_game_for_team;
- roles whose team is no longer in the sheet (or game unknown) are left alone;
- idempotent: a role already within its section band (below its own divider,
  above the next divider down) is skipped, so re-runs move nothing.

Adds RepositionSummary, TEAMSYNC_UI/OPT strings, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 16:05:38 +03:00
Rene Arumetsa
e179a35fc4 feat(teams): place new team roles under their game's divider role
Auto-created team roles now land directly under the CS2 / LoL "divider" role
in the community guild's role list, keeping it grouped by game.

- config: CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID (season-specific, env
  overridable) exposed as TEAM_DIVIDER_ROLE_IDS keyed by game code.
- core/sheets: tag each team with its game (CS2/LoL) from the section title it
  sits under; shared _scan_sections generator feeds both parse_team_rosters and
  the new parse_team_games; get_game_for_team accessor + _team_game cache.
- core/member_sync: on creating a team role, position it at its game divider's
  slot (best-effort; left in place if game/divider unknown or move refused).
- .env.example + tests for game tagging and role placement.

Each participant is in exactly one game, so one role per team is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 15:51:48 +03:00
Rene Arumetsa
a4645e19c5 Merge branch 'renkar-feat/team-roles-from-registration-sheet'
All checks were successful
Test & Deploy / test (push) Successful in 9s
Test & Deploy / deploy (push) Successful in 7s
2026-08-28 15:07:34 +03:00
Rene Arumetsa
ce1ed28904 refactor(teams): move team-role sync to the economy/community bot
The team-role feature was aimed at the wrong bot. Tournament participants
live in the economy/community guild, but team-role sync had been bolted onto
the dev bot's roster sync (sync_member), which bails out for anyone missing
from the internal member sheet - so it could never reach its actual audience.

Decouple it: keep the (roster-independent) team-sheet parsing, pull the team
wiring off the dev/member-sync path, and re-home it on the economy bot.

- core/member_sync: revert sync_member to add-only (drop team block +
  SyncResult.roles_removed); add roster-independent sync_team_role and a
  whole-guild sync_all_team_roles returning a reporting summary. Still only
  ever touches role NAMES present in the team sheet.
- commands/economy_team_commands: new admin-only /teamsync command.
- bot.py: hourly team_sync_hourly task (economy-only, no-op unless
  TEAM_SHEET_ID is set; first tick at boot covers startup load); register
  /teamsync under the economy profile; drop the dev-side startup load.
- commands/dev_member_commands: /check no longer refreshes teams or reports
  removed roles.
- strings + .env.example: TEAMSYNC_UI, CMD[teamsync], document TEAM_SHEET_ID.
- tests: retarget sync tests to sync_team_role; add sync_all_team_roles case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
2026-08-28 15:04:23 +03:00
Rene Arumetsa
52002c37fc feat(members): assign team roles from the tournament registration sheet
Match members by Discord username against the lineup nicknames in the
separate registration spreadsheet (TEAM_SHEET_ID) and give each their
team's role. One team per person: switching teams removes the old team
role, and a team with no Discord role yet is auto-created. Only role
names present in the sheet are ever touched, so organisation/field/base
roles are never at risk; the feature is a no-op when TEAM_SHEET_ID is
unset.

The sheet is not a single table (merged rows, stacked CS2/LoL sections
with different layouts), so it is parsed via raw-row scanning rather than
get_all_records. Citizenship markers like "(EST)" are used only as
player delimiters and discarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-28 14:01:22 +03:00
567a82b9f2 Merge pull request 'feat(economy): add /consumables shop of repeatable, expiring boosts' (#5) from fix/economy-money-safety into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 6s
Reviewed-on: #5
2026-08-19 17:53:12 +00:00
Rene Arumetsa
a2e1601d0e feat(economy): add /consumables shop of repeatable, expiring boosts
The economy had many coin faucets but almost no sinks: the /shop is
one-time ownership, and gambling/rob fines route to the house (which
players drain back via jackpots and heists), so they recirculate rather
than destroy coins. Result: steady inflation.

Add a consumables shop as a true recurring sink - buying destroys the
coins and grants a temporary boost, so there's always something to spend
on after gear is maxed:

  - Energiajook XL (500) - 1h of 2x earnings on /work, /beg, /crime
  - XP jook (500)        - 1h of 2x EXP
  - Kohv (300)           - instantly clears all cooldowns

Timed buffs live in a new active_buffs field ({kind: expiry_iso}), pruned
on read; rebuying extends the timer. Effects hook where they belong:
earn_mult in income.do_work/do_beg/do_crime, exp_buff_mult in
levels.award_exp; kohv is self-contained. New /consumables command browses
the menu (with active buffs) or buys a boost. Covered by 9 tests.

Note: active_buffs is a new PocketBase field - run
scripts/sync_pb_schema.py before deploying or buffs won't persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 20:32:36 +03:00
19 changed files with 1332 additions and 8 deletions

View File

@@ -9,6 +9,17 @@ DISCORD_TOKEN=
# Google Sheets spreadsheet ID (the long string in the sheet URL) # Google Sheets spreadsheet ID (the long string in the sheet URL)
SHEET_ID=your-google-sheet-id-here SHEET_ID=your-google-sheet-id-here
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
# the economy/community bot. Leave unset to disable team-role sync entirely.
TEAM_SHEET_ID=
# Team-section "divider" role IDs. A newly-created team role is positioned
# directly under the divider for its game (CS2 / LoL). Season-specific - update
# yearly. 0 = leave that game's new roles at the bottom of the role list.
CS2_DIVIDER_ROLE_ID=
LOL_DIVIDER_ROLE_ID=
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json GOOGLE_CREDS_PATH=credentials.json

46
bot.py
View File

@@ -23,7 +23,7 @@ import config
import strings as S import strings as S
from core import economy, pb_client, sheets from core import economy, pb_client, sheets
from core.admin import is_bot_admin 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_commands import register_dev_member_commands
from commands.dev_member_runtime import handle_member_join, run_birthday_daily from commands.dev_member_runtime import handle_member_join, run_birthday_daily
from commands.economy_admin_commands import register_economy_admin_commands 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_quests_commands import register_economy_quests_commands
from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_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_channel_commands import register_ops_channel_commands
from commands.ops_admin_commands import register_ops_admin_commands from commands.ops_admin_commands import register_ops_admin_commands
from commands.info_commands import register_info_commands from commands.info_commands import register_info_commands
@@ -336,6 +337,40 @@ async def before_birthday_daily():
await bot.wait_until_ready() await bot.wait_until_ready()
@tasks.loop(hours=1)
async def team_sync_hourly():
"""Reload the tournament registration sheet and re-apply team roles.
Economy profile only (the tournament players live in the community guild).
Runs the first iteration immediately on start, so this also covers the
initial load at boot. No-op when TEAM_SHEET_ID is unset.
"""
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
return
try:
rosters = await sheets.refresh_teams()
except Exception as e:
log.error("team_sync_hourly: failed to load team sheet: %s", e)
return
if not rosters:
return
guild = bot.get_guild(config.GUILD_ID)
if guild is None:
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
return
summary = await sync_all_team_roles(guild, log)
if summary.assigned or summary.removed or summary.created:
log.info(
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
)
@team_sync_hourly.before_loop
async def before_team_sync_hourly():
await bot.wait_until_ready()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rotating rich presence # Rotating rich presence
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -433,6 +468,11 @@ async def on_ready():
birthday_daily.start() birthday_daily.start()
log.info("Birthday daily task started (fires 09:00 Tallinn time)") log.info("Birthday daily task started (fires 09:00 Tallinn time)")
# Start hourly tournament team-role sync (economy/community guild)
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
team_sync_hourly.start()
log.info("Team-role sync task started (hourly, from the registration sheet)")
# Start rotating rich presence # Start rotating rich presence
if not _rotate_presence.is_running(): if not _rotate_presence.is_running():
_rotate_presence.start() _rotate_presence.start()
@@ -490,6 +530,10 @@ if IS_DEV_PROFILE:
has_announced_today=_has_announced_today, has_announced_today=_has_announced_today,
mark_announced_today=_mark_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( register_ops_admin_commands(
tree, tree,

View File

@@ -163,6 +163,81 @@ def register_economy_support_commands(
await interaction.response.send_message(embed=embed, view=view) await interaction.response.send_message(embed=embed, view=view)
view.message = await interaction.original_response() 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): class RemindersSelect(discord.ui.Select):
def __init__(self, user_id: int, current: list[str]): def __init__(self, user_id: int, current: list[str]):
self.user_id = user_id self.user_id = user_id

View File

@@ -0,0 +1,110 @@
"""Tournament team-role sync for the economy/community guild.
The tournament participants live in the *economy* (community) guild, not the
internal dev guild, so team-role assignment runs here rather than as part of the
member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately
roster-INDEPENDENT: it matches Discord usernames straight against the separate
registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet.
"""
from __future__ import annotations
import logging
import discord
from discord import app_commands
from core import sheets
from core.admin import bot_admin_check
from core.member_sync import reposition_team_roles, 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()
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
@bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if guild is None:
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
return
try:
rosters = await sheets.refresh_teams()
except Exception as e:
await interaction.followup.send(
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
)
return
if not rosters:
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
return
summary = await sync_all_team_roles(guild, log)
message = _format_summary(summary)
repo = None
if reposition:
repo = await reposition_team_roles(guild, log)
message += "\n\n" + _format_reposition(repo)
await interaction.followup.send(message, ephemeral=True)
log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
summary.scanned,
summary.assigned,
summary.removed,
len(summary.created),
len(summary.errors),
f", repositioned={repo.moved}" if repo else "",
)
def _format_summary(summary) -> str:
lines = [
S.TEAMSYNC_UI["done"],
S.TEAMSYNC_UI["scanned"].format(count=summary.scanned),
S.TEAMSYNC_UI["assigned"].format(count=summary.assigned),
S.TEAMSYNC_UI["removed"].format(count=summary.removed),
]
if summary.created:
# A team can be created only once, but the same role could surface for
# several members in one run - de-dupe for the report.
unique = list(dict.fromkeys(summary.created))
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
if summary.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
text = "\n".join(lines)
if summary.changes:
shown = summary.changes[:20]
text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown)
if len(summary.changes) > 20:
text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20)
else:
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
return text
def _format_reposition(repo) -> str:
lines = [S.TEAMSYNC_UI["reposition_header"]]
if not repo.moved:
lines.append(S.TEAMSYNC_UI["reposition_none"])
else:
lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved))
lines.extend(repo.moves[:20])
if len(repo.moves) > 20:
lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20))
if repo.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors)))
return "\n".join(lines)

View File

@@ -23,6 +23,9 @@ DISCORD_TOKEN = (
) or _LEGACY_DISCORD_TOKEN ) or _LEGACY_DISCORD_TOKEN
SHEET_ID = os.getenv("SHEET_ID") 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") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0) _LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
@@ -42,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
# Team-section "divider" roles in the community guild. When a new team role is
# auto-created it is positioned directly under the divider for its game, so the
# role list stays grouped by game. Season-specific - update yearly (or override
# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that
# game (the role is still created, just left at the bottom of the list).
CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251)
LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017)
# Keyed by the canonical game code produced by core.sheets._detect_game.
TEAM_DIVIDER_ROLE_IDS: dict[str, int] = {
"CS2": CS2_DIVIDER_ROLE_ID,
"LoL": LOL_DIVIDER_ROLE_ID,
}
def _parse_admin_roles(raw: str) -> dict[int, set[int]]: def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...". """Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".

View File

@@ -18,6 +18,7 @@ from .house import *
from .house import _credit_house, _house_record_id from .house import _credit_house, _house_record_id
from .levels import * from .levels import *
from .shop import * from .shop import *
from .consumables import *
from .fishing import * from .fishing import *
from .quests import * from .quests import *
from .quests import _ensure_quests, _pick_quests, _quest_view from .quests import _ensure_quests, _pick_quests, _quest_view
@@ -30,6 +31,6 @@ from .heist import *
from .admin import * from .admin import *
from . import ( # noqa: E402 (submodules addressable as economy.store etc.) from . import ( # noqa: E402 (submodules addressable as economy.store etc.)
admin, fishing, gambling, heist, house, income, jail, leaderboards, admin, consumables, fishing, gambling, heist, house, income, jail,
levels, prestige, quests, shop, store, leaderboards, levels, prestige, quests, shop, store,
) )

146
core/economy/consumables.py Normal file
View File

@@ -0,0 +1,146 @@
"""Consumables: repeatable, expiring boosts - a recurring coin sink.
Unlike SHOP items (permanent, bought once), consumables are bought over and
over. Buying either activates a timed buff (stored in user["active_buffs"] as
{kind: expiry_iso}) or applies an instant effect, and the coins are destroyed -
so this keeps the shop relevant, and the economy draining, after a player has
maxed out permanent gear.
Effect hooks live where the effect belongs:
- "earn" buff -> income.do_work / do_beg / do_crime (via earn_mult)
- "exp" buff -> levels.award_exp (via exp_buff_mult)
- instant kohv -> handled entirely here (wipes cooldown timestamps)
"""
from __future__ import annotations
from datetime import timedelta
from typing import TypedDict
import strings
from ..pb_client import DatabaseError
from ..emoji import EMOJI as E
from .store import _commit, _locked_by, _now, _parse_dt, _txn, get_user
class Consumable(TypedDict):
name: str
emoji: str
cost: int
description: str
kind: str # active_buffs key ("earn"/"exp"), or "instant"
duration_min: int # buff lifetime in minutes (0 for instant effects)
CONSUMABLES: dict[str, Consumable] = {
"energy_xl": {
"name": "Energiajook XL",
"emoji": E["TipiBULL"],
"cost": 500,
"description": strings.CONSUMABLE_DESCRIPTIONS["energy_xl"],
"kind": "earn",
"duration_min": 60,
},
"xp_potion": {
"name": "XP jook",
"emoji": "",
"cost": 500,
"description": strings.CONSUMABLE_DESCRIPTIONS["xp_potion"],
"kind": "exp",
"duration_min": 60,
},
"kohv": {
"name": "Kohv",
"emoji": "",
"cost": 300,
"description": strings.CONSUMABLE_DESCRIPTIONS["kohv"],
"kind": "instant",
"duration_min": 0,
},
}
# Multiplier granted while a buff of each kind is active.
_BUFF_MULT: dict[str, float] = {"earn": 2.0, "exp": 2.0}
# Cooldown timestamps an instant "kohv" clears.
_COOLDOWN_FIELDS = ("last_work", "last_beg", "last_crime", "last_rob", "last_fish")
# ---------------------------------------------------------------------------
# Buff inspection helpers (pure - safe to call while holding a user lock)
# ---------------------------------------------------------------------------
def active_buffs(user) -> dict[str, str]:
"""Return {kind: expiry_iso} for buffs that have not expired yet."""
buffs = user.get("active_buffs") or {}
now = _now()
out: dict[str, str] = {}
for kind, expiry in buffs.items():
dt = _parse_dt(expiry)
if dt is not None and dt > now:
out[kind] = expiry
return out
def buff_remaining(user, kind: str) -> timedelta | None:
"""Remaining time on a buff kind, or None if it is inactive."""
expiry = active_buffs(user).get(kind)
if expiry is None:
return None
return _parse_dt(expiry) - _now()
def earn_mult(user) -> float:
"""Earnings multiplier from an active 'earn' buff (1.0 if none)."""
return _BUFF_MULT["earn"] if "earn" in active_buffs(user) else 1.0
def exp_buff_mult(user) -> float:
"""EXP multiplier from an active 'exp' buff (1.0 if none)."""
return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0
# ---------------------------------------------------------------------------
# /consumables purchase
# ---------------------------------------------------------------------------
@_locked_by(0)
async def do_buy_consumable(user_id: int, cons_id: str) -> dict:
if cons_id not in CONSUMABLES:
return {"ok": False, "reason": "not_found"}
try:
user = await get_user(user_id)
except DatabaseError:
return {"ok": False, "reason": "db_error"}
if user.get("eco_banned"):
return {"ok": False, "reason": "banned"}
cons = CONSUMABLES[cons_id]
if user["balance"] < cons["cost"]:
return {"ok": False, "reason": "insufficient", "need": cons["cost"] - user["balance"]}
user["balance"] -= cons["cost"]
extended = False
if cons["kind"] == "instant":
for field in _COOLDOWN_FIELDS:
user[field] = None
else:
buffs = dict(user.get("active_buffs") or {})
current = _parse_dt(buffs.get(cons["kind"]))
# Stack: extend from the current expiry if still active, else from now.
extended = current is not None and current > _now()
start = current if extended else _now()
buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat()
user["active_buffs"] = buffs
await _commit(user_id, user)
_txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"])
return {
"ok": True,
"consumable": cons,
"balance": user["balance"],
"instant": cons["kind"] == "instant",
"extended": extended,
"remaining": None if cons["kind"] == "instant" else buff_remaining(user, cons["kind"]),
}

View File

@@ -14,6 +14,7 @@ from .store import (
_is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user, _is_jailed, _locked_by, _log, _now, _prestige_mult, _txn, get_user,
) )
from .house import _credit_house 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_level = (user.get("prestige_upgrades") or {}).get("work_plus", 0)
work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"] work_plus_mult = 1.0 + work_plus_level * PRESTIGE_SHOP["work_plus"]["effect"]
coin_mult, _ = _prestige_mult(user) 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["balance"] += earned
user["last_work"] = _now().isoformat() user["last_work"] = _now().isoformat()
user["work_count"] = user.get("work_count", 0) + 1 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)) jailed = bool(_is_jailed(user))
beg_mult = 2 if "klaviatuur" in user["items"] else 1 beg_mult = 2 if "klaviatuur" in user["items"] else 1
coin_mult, _ = _prestige_mult(user) 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["balance"] += earned
user["last_beg"] = _now().isoformat() user["last_beg"] = _now().isoformat()
user["beg_count"] = user.get("beg_count", 0) + 1 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) earned = random.randint(200, 500)
if "mikrofon" in user["items"]: if "mikrofon" in user["items"]:
earned = int(earned * 1.3) earned = int(earned * 1.3)
earned = int(earned * earn_mult(user))
user["balance"] += earned user["balance"] += earned
user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1 user["crimes_succeeded"] = user.get("crimes_succeeded", 0) + 1
user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned user["lifetime_earned"] = user.get("lifetime_earned", 0) + earned

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import math import math
from .store import _locked_by, _prestige_mult, get_user, _commit 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.""" """Add EXP to a user. Applies prestige exp_mult. Returns old_level, new_level, total exp."""
user = await get_user(user_id) user = await get_user(user_id)
_, exp_mult = _prestige_mult(user) _, 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) old_exp = user.get("exp", 0)
new_exp = old_exp + gained new_exp = old_exp + gained
old_level = get_level(old_exp) old_level = get_level(old_exp)

View File

@@ -106,6 +106,7 @@ class UserData(TypedDict, total=False):
last_streak_date: str | None # ISO date "YYYY-MM-DD" last_streak_date: str | None # ISO date "YYYY-MM-DD"
items: list[str] items: list[str]
item_uses: dict # {item_id: remaining_uses} for consumables 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 jailed_until: str | None # ISO datetime or None
jailbreak_used: bool jailbreak_used: bool
reminders: list[str] # command names user wants DM reminders for reminders: list[str] # command names user wants DM reminders for
@@ -159,6 +160,7 @@ def _default_user() -> UserData:
"last_streak_date": None, "last_streak_date": None,
"items": [], "items": [],
"item_uses": {}, "item_uses": {},
"active_buffs": {},
"jailed_until": None, "jailed_until": None,
"jailbreak_used": False, "jailbreak_used": False,
"reminders": ["daily", "work", "beg", "crime", "rob"], "reminders": ["daily", "work", "beg", "crime", "rob"],

View File

@@ -48,7 +48,6 @@ class SyncResult:
"""Tracks what happened during a sync operation.""" """Tracks what happened during a sync operation."""
nickname_changed: bool = False nickname_changed: bool = False
roles_added: list[str] = field(default_factory=list) roles_added: list[str] = field(default_factory=list)
roles_removed: list[str] = field(default_factory=list)
birthday_soon: bool = False birthday_soon: bool = False
birthday_today: bool = False birthday_today: bool = False
not_found: bool = False not_found: bool = False
@@ -57,7 +56,40 @@ class SyncResult:
@property @property
def changed(self) -> bool: def changed(self) -> bool:
return self.nickname_changed or self.roles_added or self.roles_removed return self.nickname_changed or self.roles_added
@dataclass
class TeamSyncResult:
"""What happened when syncing one member's tournament team role."""
added: str | None = None # team role name granted, if any
removed: list[str] = field(default_factory=list) # stale team roles taken away
created: str | None = None # team role name auto-created in the guild, if any
errors: list[str] = field(default_factory=list)
@property
def changed(self) -> bool:
return bool(self.added or self.removed)
@dataclass
class TeamSyncSummary:
"""Aggregate outcome of a whole-guild team-role sync."""
scanned: int = 0
assigned: int = 0
removed: int = 0
created: list[str] = field(default_factory=list)
changes: list[str] = field(default_factory=list) # human-readable per-member lines
errors: list[str] = field(default_factory=list)
@dataclass
class RepositionSummary:
"""Aggregate outcome of the opt-in existing-team-role reposition pass."""
scanned: int = 0 # existing team roles considered
moved: int = 0 # roles relocated under their divider
moves: list[str] = field(default_factory=list) # "role -> game" lines
errors: list[str] = field(default_factory=list)
def _format_nickname(full_name: str) -> str: def _format_nickname(full_name: str) -> str:
@@ -224,6 +256,195 @@ async def sync_member(
return result return result
async def _position_team_role_under_divider(
guild: discord.Guild,
role: discord.Role,
team_name: str,
result: TeamSyncResult,
) -> None:
"""Move a freshly-created team role directly under its game's divider role.
The team's game comes from the registration sheet; the divider role IDs come
from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is
unknown or the move is refused, the role just stays where it was created.
"""
game = sheets.get_game_for_team(team_name)
divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None
if not divider_id:
return
divider = guild.get_role(divider_id)
if divider is None:
log.warning("Divider role %s for game %s not found in guild", divider_id, game)
return
try:
await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla")
log.info("Positioned team role %r under the %s divider", team_name, game)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}")
async def sync_team_role(
member: discord.Member,
guild: discord.Guild,
) -> TeamSyncResult:
"""Give one member their tournament team role from the registration sheet.
Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the
internal member sheet at all. It matches the member's Discord username
against the team sheet caches (populated by ``sheets.refresh_teams``) and:
* grants the role for the team they're registered on (auto-creating that
role in the guild when it does not exist yet);
* removes any *other* team role they still carry (left / switched teams).
Only role NAMES present in the team sheet are ever added or removed, so no
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
are empty and this is a no-op returning an unchanged result.
"""
result = TeamSyncResult()
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
if not all_teams:
return result # feature switched off (no team sheet loaded)
desired: discord.Role | None = None
if team_name:
desired = discord.utils.get(guild.roles, name=team_name)
if desired is None:
try:
desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim")
result.created = team_name
log.info("Created team role %r for %s", team_name, member)
except discord.Forbidden:
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}")
if result.created and desired is not None:
await _position_team_role_under_divider(guild, desired, team_name, result)
# Team roles held but no longer registered for (switched teams / dropped out).
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
if desired is not None and desired not in member.roles:
try:
await member.add_roles(desired, reason="Team sync")
result.added = desired.name
except discord.Forbidden:
log.debug("No permission to add team role for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}")
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus")
result.removed = [r.name for r in to_remove]
except discord.Forbidden:
log.debug("No permission to remove team roles for %s, skipping", member)
except discord.HTTPException as e:
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}")
return result
async def sync_all_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> TeamSyncSummary:
"""Run :func:`sync_team_role` for every human member of ``guild``.
Assumes the team caches are already fresh (caller runs ``refresh_teams``
first). Returns an aggregate summary for reporting.
"""
summary = TeamSyncSummary()
for member in guild.members:
if member.bot:
continue
summary.scanned += 1
res = await sync_team_role(member, guild)
if res.created:
summary.created.append(res.created)
if res.errors:
summary.errors.extend(res.errors)
if res.added:
summary.assigned += 1
if res.removed:
summary.removed += len(res.removed)
if res.changed:
bits: list[str] = []
if res.added:
bits.append(f"+{res.added}")
if res.removed:
bits.append("-" + ", -".join(res.removed))
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
return summary
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
"""Map each configured game code to its divider role present in the guild."""
dividers: dict[str, discord.Role] = {}
for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items():
if not rid:
continue
role = guild.get_role(rid)
if role is not None:
dividers[game] = role
return dividers
async def reposition_team_roles(
guild: discord.Guild,
log: logging.Logger = log,
) -> RepositionSummary:
"""Move EXISTING team roles under their game's divider (opt-in cleanup).
Unlike creation-time placement this also relocates team roles that were made
before divider placement existed and are sitting at the bottom of the list.
Sheet-driven: a role's game (and thus its divider) comes from
``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or
whose game/divider is unknown, are left untouched.
Idempotent: a role already within its section band (below its own divider and
above the next divider down) is skipped, so re-running moves nothing.
"""
summary = RepositionSummary()
teams = sheets.all_team_names()
dividers = _resolve_dividers(guild)
if not teams or not dividers:
return summary
for role in list(guild.roles):
if role.name not in teams:
continue
summary.scanned += 1
game = sheets.get_game_for_team(role.name)
divider = dividers.get(game) if game else None
if divider is None:
continue # unknown game / divider not in guild -> leave in place
# Section band = (highest divider below this one, this divider). A role
# already inside it is grouped correctly; only relocate outliers.
lower = max(
(d.position for d in dividers.values() if d.position < divider.position),
default=0,
)
if lower < role.position < divider.position:
continue
try:
await role.edit(position=divider.position, reason="Team sync: reposition")
summary.moved += 1
summary.moves.append(f"{role.name} -> {game}")
log.info("Repositioned team role %r under the %s divider", role.name, game)
except discord.Forbidden:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus")
except discord.HTTPException as e:
summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}")
return summary
async def announce_birthday( async def announce_birthday(
member: discord.Member, member: discord.Member,
bot: discord.Client, bot: discord.Client,

View File

@@ -8,6 +8,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
import asyncio import asyncio
import logging import logging
import re
import gspread import gspread
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
@@ -250,3 +251,213 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None:
async def add_new_member_row(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).""" """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) 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"
# Canonical game code -> substrings that identify that game's section title.
# The sheet stacks a CS2 section and a LoL section, each introduced by a title
# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game
# of the section they sit under so their Discord role can be placed accordingly.
_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [
("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")),
("LoL", ("lol", "league of legends", "league")),
]
# 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_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
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 _detect_game(row: list) -> str | None:
"""Classify a row as a CS2 / LoL section title, or None if it's neither."""
text = " ".join(str(c) for c in row).lower()
for game, keys in _GAME_KEYWORDS:
if any(k in text for k in keys):
return game
return None
def _scan_sections(rows: list[list]):
"""Yield ``(team, players, game)`` for every team row across the sheet.
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. The
most recent game section title seen (via :func:`_detect_game`) tags every
team in the block that follows.
"""
current_game: str | None = None
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:
game = _detect_game(rows[i]) # title / meta row - may name the game
if game:
current_game = game
i += 1
continue
i += 1 # move past the header into the data block
while i < n:
team = _cell(rows[i], name_col).strip()
if not team or team.lower() == _TEAM_NAME_HEADER:
break # blank team-name (or a new header) ends this section
players = parse_lineup(_cell(rows[i], lineup_col))
yield team, players, current_game
i += 1
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
rosters: dict[str, list[str]] = {}
for team, players, _game in _scan_sections(rows):
if players:
rosters.setdefault(team, []).extend(players)
return rosters
def parse_team_games(rows: list[list]) -> dict[str, str]:
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
games: dict[str, str] = {}
for team, _players, game in _scan_sections(rows):
if game and team not in games:
games[team] = game
return games
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_game
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]] = {}
games: dict[str, str] = {}
for ws in spreadsheet.worksheets():
vals = ws.get_all_values()
for team, players in parse_team_rosters(vals).items():
rosters.setdefault(team, []).extend(players)
for team, game in parse_team_games(vals).items():
games.setdefault(team, game)
_team_roster = rosters
_team_by_username = build_username_index(rosters)
_team_names = set(rosters)
_team_game = games
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_game_for_team(team: str) -> str | None:
"""Return the game code ('CS2'/'LoL') a team is registered under, or None."""
return _team_game.get(team)
def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster

View File

@@ -3,6 +3,11 @@
Here you'll find an overview of TipiBOT updates. Latest changes are at the top. 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`). 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 ## 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. - Added `/quests` — daily and weekly quests with a "claim rewards" button. Three daily quests refresh every day and two weekly quests refresh every week; every player gets their own personal set that rotates over time. Complete objectives like working, fishing, wagering, or pulling off crimes to earn TipiCOIN and EXP.

View File

@@ -44,6 +44,7 @@ from .member import (
BIRTHDAY_UI, BIRTHDAY_UI,
BIRTHDAY_MONTHS, BIRTHDAY_MONTHS,
CHECK_UI, CHECK_UI,
TEAMSYNC_UI,
) )
from .economy import ( from .economy import (
@@ -56,6 +57,8 @@ from .economy import (
QUEST_DESCRIPTIONS, QUEST_DESCRIPTIONS,
SHOP_UI, SHOP_UI,
ITEM_DESCRIPTIONS, ITEM_DESCRIPTIONS,
CONSUMABLES_UI,
CONSUMABLE_DESCRIPTIONS,
JAILED_UI, JAILED_UI,
SHOP_BTN, SHOP_BTN,
DAILY_UI, DAILY_UI,
@@ -137,6 +140,7 @@ __all__ = [
'BIRTHDAY_UI', 'BIRTHDAY_UI',
'BIRTHDAY_MONTHS', 'BIRTHDAY_MONTHS',
'CHECK_UI', 'CHECK_UI',
'TEAMSYNC_UI',
'WORK_JOBS', 'WORK_JOBS',
'BEG_LINES', 'BEG_LINES',
'BEG_JAIL_LINES', 'BEG_JAIL_LINES',
@@ -146,6 +150,8 @@ __all__ = [
'QUEST_DESCRIPTIONS', 'QUEST_DESCRIPTIONS',
'SHOP_UI', 'SHOP_UI',
'ITEM_DESCRIPTIONS', 'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',

View File

@@ -24,6 +24,7 @@ CMD: dict[str, str] = {
"check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed", "check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed",
"sync": "Sünkroniseeri käsklused Discordi serveriga", "sync": "Sünkroniseeri käsklused Discordi serveriga",
"member": "Näita liikme andmeid tabelist", "member": "Näita liikme andmeid tabelist",
"teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist",
"restart": "Tee taaskäivitus botile", "restart": "Tee taaskäivitus botile",
"shutdown": "Lülita bot välja (ilma taaskäivituseta)", "shutdown": "Lülita bot välja (ilma taaskäivituseta)",
"pause": "Peata / jätka kõik käsklused (hooldusrežiim)", "pause": "Peata / jätka kõik käsklused (hooldusrežiim)",
@@ -73,6 +74,7 @@ CMD: dict[str, str] = {
"fishsell": "Müü kalu oma inventarist", "fishsell": "Müü kalu oma inventarist",
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi",
"quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad", "quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad",
"consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -80,6 +82,7 @@ CMD: dict[str, str] = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
OPT: dict[str, str] = { OPT: dict[str, str] = {
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
"admin_kasutaja": "Kasutaja", "admin_kasutaja": "Kasutaja",
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)", "admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
"admincoins_kogus": "Positiivne = anna, negatiivne = võta", "admincoins_kogus": "Positiivne = anna, negatiivne = võta",
@@ -90,6 +93,7 @@ OPT: dict[str, str] = {
"give_kasutaja": "Kellele annad?", "give_kasutaja": "Kellele annad?",
"give_summa": "Kui palju annad? ('all' = kogu saldo)", "give_summa": "Kui palju annad? ('all' = kogu saldo)",
"buy_ese": "Eseme nimi (vaata /shop)", "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_panus": "Valikuline TipiCOINide panus ('all' = kogu saldo)",
"rps_vastane": "Väljakutse teisele mängijale (PvP)", "rps_vastane": "Väljakutse teisele mängijale (PvP)",
"slots_panus": "Panus TipiCOINides ('all' = kogu saldo)", "slots_panus": "Panus TipiCOINides ('all' = kogu saldo)",

View File

@@ -15,6 +15,8 @@ __all__ = [
'QUEST_DESCRIPTIONS', 'QUEST_DESCRIPTIONS',
'SHOP_UI', 'SHOP_UI',
'ITEM_DESCRIPTIONS', 'ITEM_DESCRIPTIONS',
'CONSUMABLES_UI',
'CONSUMABLE_DESCRIPTIONS',
'JAILED_UI', 'JAILED_UI',
'SHOP_BTN', 'SHOP_BTN',
'DAILY_UI', 'DAILY_UI',
@@ -214,6 +216,30 @@ ITEM_DESCRIPTIONS: dict[str, str] = {
"kalavork": "Suurem võrk = suuremad kalad. Kõigi kalade haruldus tõuseb ühe astme võrra.", "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.", "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] = { JAILED_UI: dict[str, str] = {
"title": "🔒 Praegu vanglas", "title": "🔒 Praegu vanglas",
"empty": "Kõik on vabad! Vanglas pole kedagi.", "empty": "Kõik on vabad! Vanglas pole kedagi.",

View File

@@ -11,6 +11,7 @@ __all__ = [
'BIRTHDAY_UI', 'BIRTHDAY_UI',
'BIRTHDAY_MONTHS', 'BIRTHDAY_MONTHS',
'CHECK_UI', 'CHECK_UI',
'TEAMSYNC_UI',
] ]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -94,3 +95,25 @@ CHECK_UI: dict[str, str] = {
"detail_changed": "🔧 **{name}**: {parts}", "detail_changed": "🔧 **{name}**: {parts}",
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.", "ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
} }
# ---------------------------------------------------------------------------
# /teamsync UI strings (tournament team-role sync from the registration sheet)
# ---------------------------------------------------------------------------
TEAMSYNC_UI: dict[str, str] = {
"disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).",
"refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}",
"done": "**Tiimide sünkroonimine lõpetatud!**",
"scanned": "👥 Kontrollitud liikmeid: {count}",
"assigned": "✅ Tiimirolle antud: {count}",
"removed": " Tiimirolle eemaldatud: {count}",
"created": "🆕 Loodud uusi tiimirolle: {roles}",
"errors": "⚠️ Vead: {count}",
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**",
"changes_more": "... ja {count} rohkem",
"reposition_header": "**Ümberpaigutus:**",
"repositioned": "📦 Ümber paigutatud rolle: {count}",
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
}

102
tests/test_consumables.py Normal file
View File

@@ -0,0 +1,102 @@
"""Tests for the consumables shop (repeatable, expiring coin sink)."""
from datetime import datetime, timedelta, timezone
from core import economy
from conftest import run
UID = 4242
def _fixed_now(monkeypatch, dt: datetime):
# _clock is the time seam shared by every economy submodule
monkeypatch.setattr(economy.store, "_clock", lambda: dt)
return dt
def _fund(fake_pb, amount: int) -> None:
run(economy.get_user(UID))
fake_pb.record_for(UID)["balance"] = amount
class TestBuy:
def test_buy_earn_buff_deducts_and_activates(self, fake_pb):
_fund(fake_pb, 1000)
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert res["ok"] and not res["instant"]
assert res["balance"] == 500 # 1000 - 500 cost
user = run(economy.get_user(UID))
assert economy.earn_mult(user) == 2.0
assert economy.exp_buff_mult(user) == 1.0
def test_insufficient_funds_rejected(self, fake_pb):
_fund(fake_pb, 100)
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert not res["ok"] and res["reason"] == "insufficient"
assert res["need"] == 400
assert run(economy.get_user(UID))["balance"] == 100 # unchanged
def test_banned_rejected(self, fake_pb):
_fund(fake_pb, 1000)
fake_pb.record_for(UID)["eco_banned"] = True
res = run(economy.do_buy_consumable(UID, "energy_xl"))
assert not res["ok"] and res["reason"] == "banned"
def test_unknown_consumable(self, fake_pb):
_fund(fake_pb, 1000)
res = run(economy.do_buy_consumable(UID, "nope"))
assert not res["ok"] and res["reason"] == "not_found"
class TestBuffLifecycle:
def test_buff_expires(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
run(economy.do_buy_consumable(UID, "energy_xl"))
assert economy.earn_mult(run(economy.get_user(UID))) == 2.0
# 61 minutes later the 60-minute buff is gone
_fixed_now(monkeypatch, t0 + timedelta(minutes=61))
assert economy.earn_mult(run(economy.get_user(UID))) == 1.0
def test_rebuy_extends_duration(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 2000)
run(economy.do_buy_consumable(UID, "energy_xl")) # expiry = t0 + 60m
_fixed_now(monkeypatch, t0 + timedelta(minutes=30))
res = run(economy.do_buy_consumable(UID, "energy_xl")) # extends, not resets
assert res["extended"] is True
# remaining should be ~90m (30m left + 60m added), not 60m
assert res["remaining"] > timedelta(minutes=85)
class TestEffects:
def test_earn_buff_doubles_work(self, fake_pb, monkeypatch):
import random
_fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
monkeypatch.setattr(random, "randint", lambda a, b: 50)
monkeypatch.setattr(random, "choice", lambda seq: seq[0])
monkeypatch.setattr(random, "random", lambda: 0.99) # no energiajook luck
base = run(economy.do_work(UID))["earned"]
fake_pb.record_for(UID)["last_work"] = None # clear cooldown
run(economy.do_buy_consumable(UID, "energy_xl"))
boosted = run(economy.do_work(UID))["earned"]
assert boosted == base * 2
def test_exp_buff_doubles_award(self, fake_pb):
_fund(fake_pb, 1000)
run(economy.do_buy_consumable(UID, "xp_potion"))
res = run(economy.award_exp(UID, 10))
assert res["gained"] == 20 # 10 * 2x exp buff
def test_kohv_clears_cooldowns(self, fake_pb, monkeypatch):
t0 = _fixed_now(monkeypatch, datetime(2026, 8, 19, 12, tzinfo=timezone.utc))
_fund(fake_pb, 1000)
rec = fake_pb.record_for(UID)
rec["last_work"] = t0.isoformat() # on cooldown
res = run(economy.do_buy_consumable(UID, "kohv"))
assert res["ok"] and res["instant"]
user = run(economy.get_user(UID))
assert user["last_work"] is None # cooldown wiped -> /work is ready
assert economy.store._cooldown_remaining(user, "work") is None

318
tests/test_team_sync.py Normal file
View File

@@ -0,0 +1,318 @@
"""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"]]) == {}
def test_parse_team_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
assert sheets.parse_team_games(SHEET_ROWS) == {
"Piirivalvurid": "CS2",
"GENESIS": "CS2",
"Pushing 30s": "LoL",
}
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, position: int = 0):
self.id = rid
self.name = name
self.position = position
async def edit(self, position=None, reason=None):
if position is not None:
self.position = position
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] = []
def get_role(self, rid):
return next((r for r in self.roles if r.id == rid), None)
async def create_role(self, name, reason=None):
self._next += 1
role = FakeRole(self._next, name)
self.roles.append(role)
self.created.append(name)
return role
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
old_team = FakeRole(1, "OldTeam")
keeper = FakeRole(2, "Member") # not a team role - must be left alone
member = FakeMember(1, "tft", roles=[old_team, keeper])
guild = FakeGuild([old_team, keeper])
monkeypatch.setattr(sheets, "get_team_for_username",
lambda n: "GENESIS" if n.lower() == "tft" else None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "GENESIS" # auto-created the missing role
assert "GENESIS" in guild.created
assert result.added == "GENESIS"
assert result.removed == ["OldTeam"] # left their previous team
role_names = {r.name for r in member.roles}
assert "GENESIS" in role_names
assert "OldTeam" not in role_names
assert "Member" in role_names # unrelated role untouched
def test_sync_uses_existing_team_role(monkeypatch):
genesis = FakeRole(3, "GENESIS")
member = FakeMember(1, "kapa", roles=[])
guild = FakeGuild([genesis])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
result = run(member_sync.sync_team_role(member, guild))
assert guild.created == [] # did NOT create a duplicate
assert result.created is None
assert result.added == "GENESIS"
assert genesis in member.roles
def test_sync_strips_team_role_when_not_registered(monkeypatch):
old_team = FakeRole(1, "OldTeam")
member = FakeMember(1, "ghost", roles=[old_team])
guild = FakeGuild([old_team])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"})
result = run(member_sync.sync_team_role(member, guild))
assert result.removed == ["OldTeam"]
assert result.added is None
assert old_team not in member.roles
def test_sync_no_team_sheet_is_noop(monkeypatch):
keeper = FakeRole(2, "Member")
member = FakeMember(1, "someone", roles=[keeper])
guild = FakeGuild([keeper])
# Empty caches = feature switched off: no removals even of a stale team role.
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None)
monkeypatch.setattr(sheets, "all_team_names", lambda: set())
result = run(member_sync.sync_team_role(member, guild))
assert result.removed == []
assert guild.created == []
assert keeper in member.roles
def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
genesis = FakeRole(3, "GENESIS")
m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS
m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged
bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped
guild = FakeGuild([genesis], members=[m1, m2, bot_member])
teams = {"kapa": "GENESIS"}
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower()))
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
summary = run(member_sync.sync_all_team_roles(guild))
assert summary.scanned == 2 # bot excluded
assert summary.assigned == 1
assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"]
def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch):
divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10)
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([divider], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2")
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM"
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 10 # slotted at the divider
def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch):
member = FakeMember(1, "tft", roles=[])
guild = FakeGuild([], members=[member])
monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM")
monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500})
result = run(member_sync.sync_team_role(member, guild))
assert result.created == "NEWTEAM" # still created, just not moved
new_role = next(r for r in guild.roles if r.name == "NEWTEAM")
assert new_role.position == 0 # default, untouched
# --- reposition_team_roles (opt-in cleanup of existing roles) ---------------
def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom
pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band
unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored
guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated])
games = {"GENESIS": "CS2", "Pushing 30s": "LoL"}
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t))
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 2 # only the two team roles
assert repo.moved == 1 # only GENESIS was out of place
assert repo.moves == ["GENESIS -> CS2"]
assert genesis.position == 20 # moved under the CS2 divider
assert pushing.position == 5 # already in its band, untouched
assert unrelated.position == 30 # non-team role never considered
def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch):
cs2_div = FakeRole(100, "CS2 divider", position=20)
lol_div = FakeRole(200, "LoL divider", position=10)
ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet
guild = FakeGuild([cs2_div, lol_div, ghost])
# Team no longer registered -> not in all_team_names, so never scanned/moved.
monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"})
monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None)
monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200})
repo = run(member_sync.reposition_team_roles(guild))
assert repo.scanned == 0
assert repo.moved == 0
assert ghost.position == 2 # left exactly where it was