2 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
11 changed files with 300 additions and 415 deletions

View File

@@ -14,14 +14,11 @@ SHEET_ID=your-google-sheet-id-here
# the economy/community bot. Leave unset to disable team-role sync entirely. # the economy/community bot. Leave unset to disable team-role sync entirely.
TEAM_SHEET_ID= TEAM_SHEET_ID=
# Where each game's team roles get positioned in the role list. The key suffix # Team-section "divider" role IDs. A newly-created team role is positioned
# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2 # directly under the divider for its game (CS2 / LoL). Season-specific - update
# Registration Log") - every underscore-separated part must appear in it, so # yearly. 0 = leave that game's new roles at the bottom of the role list.
# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any CS2_DIVIDER_ROLE_ID=
# year. The value is the EXACT Discord role name to place those teams under. LOL_DIVIDER_ROLE_ID=
# Optional: sections that match nothing still get their roles, just unpositioned.
TEAM_DIVIDER_CS2_2026=
TEAM_DIVIDER_LOL_2026=
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json GOOGLE_CREDS_PATH=credentials.json

View File

@@ -10,9 +10,8 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go
2. [Member Management](#member-management) 2. [Member Management](#member-management)
3. [Admin Commands](#admin-commands) 3. [Admin Commands](#admin-commands)
4. [Birthday System](#birthday-system) 4. [Birthday System](#birthday-system)
5. [Tournament Team Roles](#tournament-team-roles) 5. [TipiCOIN Economy](#tipicoin-economy)
6. [TipiCOIN Economy](#tipicoin-economy) 6. [Project Structure](#project-structure)
7. [Project Structure](#project-structure)
--- ---
@@ -108,8 +107,6 @@ cp .env.example .env
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` | | `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_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) | | `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 ### 6. Install & Run
@@ -171,7 +168,6 @@ 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 | | `/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 | | `/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 | | `/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 | | `/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 | | `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting |
@@ -224,46 +220,6 @@ 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 ## 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/store.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`.

9
bot.py
View File

@@ -359,14 +359,11 @@ async def team_sync_hourly():
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID) log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
return return
summary = await sync_all_team_roles(guild, log) summary = await sync_all_team_roles(guild, log)
if summary.assigned or summary.removed or summary.created or summary.positioned or summary.errors: if summary.assigned or summary.removed or summary.created:
log.info( log.info(
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", "team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
summary.assigned, summary.removed, len(summary.created), summary.assigned, summary.removed, len(summary.created), len(summary.errors),
summary.positioned, len(summary.errors),
) )
for err in summary.errors:
log.warning("team_sync_hourly: %s", err)
@team_sync_hourly.before_loop @team_sync_hourly.before_loop

View File

@@ -16,7 +16,7 @@ from discord import app_commands
from core import sheets from core import sheets
from core.admin import bot_admin_check from core.admin import bot_admin_check
from core.member_sync import sync_all_team_roles from core.member_sync import reposition_team_roles, sync_all_team_roles
import strings as S import strings as S
@@ -27,8 +27,9 @@ def register_economy_team_commands(
) -> None: ) -> None:
@tree.command(name="teamsync", description=S.CMD["teamsync"]) @tree.command(name="teamsync", description=S.CMD["teamsync"])
@app_commands.guild_only() @app_commands.guild_only()
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
@bot_admin_check() @bot_admin_check()
async def cmd_teamsync(interaction: discord.Interaction): async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
guild = interaction.guild guild = interaction.guild
@@ -48,15 +49,22 @@ def register_economy_team_commands(
return return
summary = await sync_all_team_roles(guild, log) summary = await sync_all_team_roles(guild, log)
await interaction.followup.send(_format_summary(summary), ephemeral=True) 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( log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
summary.scanned, summary.scanned,
summary.assigned, summary.assigned,
summary.removed, summary.removed,
len(summary.created), len(summary.created),
summary.positioned,
len(summary.errors), len(summary.errors),
f", repositioned={repo.moved}" if repo else "",
) )
@@ -72,8 +80,6 @@ def _format_summary(summary) -> str:
# several members in one run - de-dupe for the report. # several members in one run - de-dupe for the report.
unique = list(dict.fromkeys(summary.created)) unique = list(dict.fromkeys(summary.created))
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique))) 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.errors: if summary.errors:
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors))) lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
@@ -88,3 +94,17 @@ def _format_summary(summary) -> str:
text += "\n\n" + S.TEAMSYNC_UI["no_changes"] text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
return text 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

@@ -45,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...".
@@ -69,36 +82,6 @@ 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", "")) 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, str]:
"""Collect TEAM_DIVIDER_<SUFFIX> env vars into {suffix: divider role name}.
The suffix says which sheet sections the divider covers, the value is the
exact Discord role name their teams get positioned under:
TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======"
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, str] = {}
for key, value in os.environ.items():
if not key.startswith(_TEAM_DIVIDER_PREFIX):
continue
suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower()
name = value.strip()
if suffix and name:
dividers[suffix] = name
return dividers
TEAM_DIVIDERS: dict[str, str] = _parse_team_dividers()
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")

View File

@@ -79,11 +79,19 @@ class TeamSyncSummary:
assigned: int = 0 assigned: int = 0
removed: int = 0 removed: int = 0
created: list[str] = field(default_factory=list) created: list[str] = field(default_factory=list)
positioned: int = 0 # team roles moved under a divider
changes: list[str] = field(default_factory=list) # human-readable per-member lines changes: list[str] = field(default_factory=list) # human-readable per-member lines
errors: list[str] = field(default_factory=list) 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:
"""Format a nickname from a full name: first name + last name initial. """Format a nickname from a full name: first name + last name initial.
@@ -248,6 +256,35 @@ 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( async def sync_team_role(
member: discord.Member, member: discord.Member,
guild: discord.Guild, guild: discord.Guild,
@@ -285,6 +322,8 @@ async def sync_team_role(
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus") result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
except discord.HTTPException as e: except discord.HTTPException as e:
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {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). # 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] to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
@@ -310,106 +349,6 @@ async def sync_team_role(
return result 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 role, the rest are left exactly where they are.
Returns ``(roles_moved, errors)``; a no-op returns ``(0, [])``.
"""
placements: dict[str, list[str]] = {}
for team, divider in sheets.get_team_dividers().items():
placements.setdefault(divider, []).append(team)
if not placements:
return 0, [] # no dividers configured, or nothing matched
errors: list[str] = []
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}")
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( async def sync_all_team_roles(
guild: discord.Guild, guild: discord.Guild,
log: logging.Logger = log, log: logging.Logger = log,
@@ -440,11 +379,69 @@ async def sync_all_team_roles(
if res.removed: if res.removed:
bits.append("-" + ", -".join(res.removed)) bits.append("-" + ", -".join(res.removed))
summary.changes.append(f"{member.display_name}: {', '.join(bits)}") summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
return summary
# 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. def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
summary.positioned, position_errors = await apply_team_role_positions(guild, log) """Map each configured game code to its divider role present in the guild."""
summary.errors.extend(position_errors) 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 return summary

View File

@@ -9,7 +9,6 @@ Pure-cache helpers (get_cache, find_*) remain sync.
import asyncio import asyncio
import logging import logging
import re import re
from dataclasses import dataclass, field
import gspread import gspread
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
@@ -276,23 +275,20 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
_TEAM_NAME_HEADER = "team name" _TEAM_NAME_HEADER = "team name"
_LINEUP_HEADER_PREFIX = "lineup" _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-sheet caches (mirrors the member-roster cache above)
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...] _team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
_team_by_username: dict[str, str] = {} # normalized username -> team name _team_by_username: dict[str, str] = {} # normalized username -> team name
_team_names: set[str] = set() # universe of all team names _team_names: set[str] = set() # universe of all team names
_team_divider: dict[str, str] = {} # team name -> divider role name _team_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
@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]: def parse_lineup(cell: str) -> list[str]:
@@ -335,82 +331,62 @@ def _cell(row: list, idx: int) -> str:
return str(row[idx]) if 0 <= idx < len(row) else "" return str(row[idx]) if 0 <= idx < len(row) else ""
def _merged_title(row: list) -> str | None: def _detect_game(row: list) -> str | None:
"""Return a row's lone non-empty cell - a merged section title - else 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()
A fully blank row returns None rather than "", so blank separators between for game, keys in _GAME_KEYWORDS:
sections do not wipe the title we are holding for the next header row. if any(k in text for k in keys):
""" return game
values = [str(c).strip() for c in row if str(c).strip()] return None
return values[0] if len(values) == 1 else None
def parse_team_sections(rows: list[list]) -> list[TeamSection]: def _scan_sections(rows: list[list]):
"""Extract each game's block of teams from a tab's raw rows, with its title. """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...' 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 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. positions) until the team-name column goes blank or a new header appears.
Handles multiple stacked sections with differing layouts in one tab, and Handles multiple stacked sections with differing layouts in one tab. The
tags each with the most recent merged title row seen above it. most recent game section title seen (via :func:`_detect_game`) tags every
team in the block that follows.
""" """
sections: list[TeamSection] = [] current_game: str | None = None
title = ""
i, n = 0, len(rows) i, n = 0, len(rows)
while i < n: while i < n:
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER) 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)) 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 name_col is None or lineup_col is None:
if (text := _merged_title(rows[i])) is not None: game = _detect_game(rows[i]) # title / meta row - may name the game
title = text if game:
current_game = game
i += 1 i += 1
continue continue
rosters: dict[str, list[str]] = {}
i += 1 # move past the header into the data block i += 1 # move past the header into the data block
while i < n: while i < n:
team = _cell(rows[i], name_col).strip() team = _cell(rows[i], name_col).strip()
if not team or team.lower() == _TEAM_NAME_HEADER: if not team or team.lower() == _TEAM_NAME_HEADER:
break # blank team-name (or a new header) ends this section break # blank team-name (or a new header) ends this section
players = parse_lineup(_cell(rows[i], lineup_col)) players = parse_lineup(_cell(rows[i], lineup_col))
if players: yield team, players, current_game
rosters.setdefault(team, []).extend(players)
i += 1 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]]: def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
"""Flatten every section in a tab into {team_name: [nickname, ...]}.""" """Extract {team_name: [nickname, ...]} from a tab's raw rows."""
rosters: dict[str, list[str]] = {} rosters: dict[str, list[str]] = {}
for section in parse_team_sections(rows): for team, players, _game in _scan_sections(rows):
for team, players in section.rosters.items(): if players:
rosters.setdefault(team, []).extend(players) rosters.setdefault(team, []).extend(players)
return rosters return rosters
def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str | None: def parse_team_games(rows: list[list]) -> dict[str, str]:
"""Return the divider role name configured for a section title, if any. """Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
games: dict[str, str] = {}
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated for team, _players, game in _scan_sections(rows):
part of its suffix appears as a whole word in the title, so ``CS2`` matches if game and team not in games:
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one. games[team] = game
The most specific match (most parts) wins, which lets a year-scoped entry return games
override a general one for the same game.
"""
if dividers is None:
dividers = config.TEAM_DIVIDERS
haystack = title.lower()
best_name: str | None = None
best_parts = 0
for suffix, role_name 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_name, best_parts = role_name, len(parts)
return best_name
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
@@ -435,25 +411,24 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
def _refresh_teams_sync() -> dict[str, list[str]]: def _refresh_teams_sync() -> dict[str, list[str]]:
global _team_roster, _team_by_username, _team_names, _team_divider global _team_roster, _team_by_username, _team_names, _team_game
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES) creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
client = gspread.authorize(creds) client = gspread.authorize(creds)
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID) spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
rosters: dict[str, list[str]] = {} rosters: dict[str, list[str]] = {}
dividers: dict[str, str] = {} games: dict[str, str] = {}
for ws in spreadsheet.worksheets(): for ws in spreadsheet.worksheets():
for section in parse_team_sections(ws.get_all_values()): vals = ws.get_all_values()
divider = resolve_divider(section.title) for team, players in parse_team_rosters(vals).items():
for team, players in section.rosters.items():
rosters.setdefault(team, []).extend(players) rosters.setdefault(team, []).extend(players)
if divider: for team, game in parse_team_games(vals).items():
dividers[team] = divider games.setdefault(team, game)
_team_roster = rosters _team_roster = rosters
_team_by_username = build_username_index(rosters) _team_by_username = build_username_index(rosters)
_team_names = set(rosters) _team_names = set(rosters)
_team_divider = dividers _team_game = games
return rosters return rosters
@@ -478,15 +453,11 @@ def all_team_names() -> set[str]:
return set(_team_names) 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]]: def get_team_rosters() -> dict[str, list[str]]:
"""Current {team: [usernames]} cache (mainly for diagnostics/tests).""" """Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
return _team_roster return _team_roster
def get_team_dividers() -> dict[str, str]:
"""Current {team: divider role name} 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)

View File

@@ -19,7 +19,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord
| `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/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/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
| `core/sheets.py` | Google Sheets integration (member sync) | | `core/sheets.py` | Google Sheets integration (member sync) |
| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement | | `core/member_sync.py` | Birthday/member sync helpers |
### `commands/` - one slash-command group per file ### `commands/` - one slash-command group per file
@@ -148,37 +148,6 @@ Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FI
--- ---
## 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.
---
## Role Hierarchy (Discord) ## Role Hierarchy (Discord)
Order top to bottom in server roles: Order top to bottom in server roles:

View File

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

View File

@@ -109,9 +109,11 @@ TEAMSYNC_UI: dict[str, str] = {
"assigned": "✅ Tiimirolle antud: {count}", "assigned": "✅ Tiimirolle antud: {count}",
"removed": " Tiimirolle eemaldatud: {count}", "removed": " Tiimirolle eemaldatud: {count}",
"created": "🆕 Loodud uusi tiimirolle: {roles}", "created": "🆕 Loodud uusi tiimirolle: {roles}",
"positioned": "📍 Eraldaja alla paigutatud: {count}",
"errors": "⚠️ Vead: {count}", "errors": "⚠️ Vead: {count}",
"no_changes": "✨ Kõik tiimirollid olid juba korras.", "no_changes": "✨ Kõik tiimirollid olid juba korras.",
"changes_header": "**Muudatused:**", "changes_header": "**Muudatused:**",
"changes_more": "... ja {count} rohkem", "changes_more": "... ja {count} rohkem",
"reposition_header": "**Ümberpaigutus:**",
"repositioned": "📦 Ümber paigutatud rolle: {count}",
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
} }

View File

@@ -13,6 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import member_sync, sheets # noqa: E402 from core import member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402 from tests.conftest import run # noqa: E402
@@ -87,108 +88,15 @@ def test_parse_team_rosters_ignores_non_table_content():
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {} 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_games_tags_each_team_with_its_section():
# Teams inherit the game of the stacked section they sit under.
def test_parse_team_sections_tags_each_block_with_its_title(): assert sheets.parse_team_games(SHEET_ROWS) == {
sections = sheets.parse_team_sections(SHEET_ROWS) "Piirivalvurid": "CS2",
assert [(s.title, sorted(s.rosters)) for s in sections] == [ "GENESIS": "CS2",
("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]), "Pushing 30s": "LoL",
("[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": "====== COUNTER-STRIKE 2 2026 ======",
"lol_2026": "===== LEAGUE OF LEGENDS 2026 =====",
} }
def test_resolve_divider_matches_game_and_year():
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS
) == "====== COUNTER-STRIKE 2 2026 ======"
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS
) == "===== LEAGUE OF LEGENDS 2026 ====="
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": "== CS2 ALL YEARS ==", "cs2_2026": "== CS2 2026 =="}
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == "== CS2 2026 =="
assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == "== CS2 ALL YEARS =="
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
# --- 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(): def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
@@ -197,9 +105,14 @@ def test_build_username_index_is_case_insensitive():
# --- sync_team_role behaviour (roster-independent) ------------------------- # --- sync_team_role behaviour (roster-independent) -------------------------
class FakeRole: class FakeRole:
def __init__(self, rid: int, name: str): def __init__(self, rid: int, name: str, position: int = 0):
self.id = rid self.id = rid
self.name = name 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): def __eq__(self, other):
return isinstance(other, FakeRole) and other.id == self.id return isinstance(other, FakeRole) and other.id == self.id
@@ -230,6 +143,9 @@ class FakeGuild:
self._next = 9000 self._next = 9000
self.created: list[str] = [] 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): async def create_role(self, name, reason=None):
self._next += 1 self._next += 1
role = FakeRole(self._next, name) role = FakeRole(self._next, name)
@@ -324,3 +240,79 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
assert summary.assigned == 1 assert summary.assigned == 1
assert summary.removed == 0 assert summary.removed == 0
assert summary.changes == ["kapa: +GENESIS"] 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