From deab0f7a5550d3b74045802e20e53e7294ce6700 Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Thu, 3 Sep 2026 21:36:41 +0300 Subject: [PATCH] CS2 team dividers. --- .env.example | 9 +++ README.md | 48 +++++++++++++- bot.py | 9 ++- commands/economy_team_commands.py | 5 +- config.py | 30 +++++++++ core/member_sync.py | 106 ++++++++++++++++++++++++++++++ core/sheets.py | 93 ++++++++++++++++++++++++-- docs/DEV_NOTES.md | 33 +++++++++- strings/member.py | 1 + tests/test_team_sync.py | 102 ++++++++++++++++++++++++++++ 10 files changed, 422 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 0851cee..494e974 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ SHEET_ID=your-google-sheet-id-here # the economy/community bot. Leave unset to disable team-role sync entirely. TEAM_SHEET_ID= +# Where each game's team roles get positioned in the role list. The key suffix +# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2 +# Registration Log") - every underscore-separated part must appear in it, so +# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any +# year. The value is the EXACT Discord role name to place those teams under. +# 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 GOOGLE_CREDS_PATH=credentials.json diff --git a/README.md b/README.md index f400608..1091252 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go 2. [Member Management](#member-management) 3. [Admin Commands](#admin-commands) 4. [Birthday System](#birthday-system) -5. [TipiCOIN Economy](#tipicoin-economy) -6. [Project Structure](#project-structure) +5. [Tournament Team Roles](#tournament-team-roles) +6. [TipiCOIN Economy](#tipicoin-economy) +7. [Project Structure](#project-structure) --- @@ -107,6 +108,8 @@ cp .env.example .env | `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` | | `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` | | `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) | +| `TEAM_SHEET_ID` | Tournament registration spreadsheet (separate from the member sheet). Unset = team sync off | +| `TEAM_DIVIDER__` | Divider role that a sheet section's teams get positioned under - see [Tournament team roles](#tournament-team-roles) | ### 6. Install & Run @@ -168,6 +171,7 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark |---|---|---| | `/check` | Manage Roles | Refreshes sheet data, backfills missing User IDs, syncs nicknames + roles for every member, reports stats | | `/member @user` | Manage Roles | Shows a member's full sheet data + calculated age | +| `/teamsync` | Bot admin | Reloads the tournament registration sheet, grants/removes team roles, and repositions them under their dividers. See [Tournament Team Roles](#tournament-team-roles) | | `/sync` | Manage Guild | Re-registers slash commands with Discord | | `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up | | `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting | @@ -220,6 +224,46 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi --- +## Tournament Team Roles + +Runs on the **economy** profile only, driven by the separate `TEAM_SHEET_ID` spreadsheet. It is independent of the member roster - players are matched straight from the registration sheet by Discord username, and never need a row in the member sheet. + +Re-runs automatically **every hour**, and on demand via `/teamsync` (admin). + +### What it does + +- Grants each registered player the role named after their team, creating that role if it doesn't exist yet +- Removes team roles they no longer hold (switched teams, dropped out) +- Positions every team role directly beneath its game's divider role +- **Only role names present in the sheet are ever added, removed, or moved** - no unrelated role is at risk + +### Divider placement + +The registration sheet stacks several game sections per tab, each under a merged title row: + +``` +[merged] TipiLAN 2026 CS2 Registration Log +No | Team Name | Lineup (nickname, citizenship) | ... +1 | GENESIS | kapa (EST), neaQ (EST) | ... +``` + +Set one `TEAM_DIVIDER_*` variable per section to say where those teams belong: + +```bash +TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======" +TEAM_DIVIDER_LOL_2026="===== LEAGUE OF LEGENDS 2026 =====" +``` + +- **The key suffix** is matched against the section's title row. Every underscore-separated part must appear in it as a whole word, so `CS2_2026` matches only the 2026 CS2 block, while a plain `CS2` would match that game in any year. When several keys match, the most specific one (most parts) wins. +- **The value** is the *exact* Discord role name, decoration included. Copy it from Server Settings → Roles. +- Sections matching no key still get their team roles - they just aren't repositioned. + +Teams are ordered alphabetically downwards under their divider. Every sync re-checks placement, so roles that predate this feature get pulled into place on the next run. + +> The bot can only move roles **below its own** role. Drag the TipiBOT role above your dividers, or placement is skipped and reported as an error. + +--- + ## TipiCOIN Economy All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy/store.py → COIN`. diff --git a/bot.py b/bot.py index 3411f44..20392dd 100644 --- a/bot.py +++ b/bot.py @@ -359,11 +359,14 @@ async def team_sync_hourly(): 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: + if summary.assigned or summary.removed or summary.created or summary.positioned or summary.errors: 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: assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", + summary.assigned, summary.removed, len(summary.created), + summary.positioned, len(summary.errors), ) + for err in summary.errors: + log.warning("team_sync_hourly: %s", err) @team_sync_hourly.before_loop diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py index 6559c7b..8bf88ca 100644 --- a/commands/economy_team_commands.py +++ b/commands/economy_team_commands.py @@ -50,11 +50,12 @@ def register_economy_team_commands( summary = await sync_all_team_roles(guild, log) await interaction.followup.send(_format_summary(summary), ephemeral=True) log.info( - "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d", + "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", summary.scanned, summary.assigned, summary.removed, len(summary.created), + summary.positioned, len(summary.errors), ) @@ -71,6 +72,8 @@ def _format_summary(summary) -> str: # several members in one run - de-dupe for the report. unique = list(dict.fromkeys(summary.created)) lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique))) + if summary.positioned: + lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned)) if summary.errors: lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors))) diff --git a/config.py b/config.py index 37435ad..717b734 100644 --- a/config.py +++ b/config.py @@ -69,6 +69,36 @@ def _parse_admin_roles(raw: str) -> dict[int, set[int]]: BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", "")) +_TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_" + + +def _parse_team_dividers() -> dict[str, str]: + """Collect TEAM_DIVIDER_ 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_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") diff --git a/core/member_sync.py b/core/member_sync.py index 0c89117..1bf89d4 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -79,6 +79,7 @@ class TeamSyncSummary: assigned: int = 0 removed: int = 0 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 errors: list[str] = field(default_factory=list) @@ -309,6 +310,106 @@ async def sync_team_role( 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( guild: discord.Guild, log: logging.Logger = log, @@ -339,6 +440,11 @@ async def sync_all_team_roles( if res.removed: bits.append("-" + ", -".join(res.removed)) summary.changes.append(f"{member.display_name}: {', '.join(bits)}") + + # Placement runs after the grant/remove pass so roles created this run are + # positioned in the same sweep rather than waiting for the next one. + summary.positioned, position_errors = await apply_team_role_positions(guild, log) + summary.errors.extend(position_errors) return summary diff --git a/core/sheets.py b/core/sheets.py index 530bc8e..105bdb3 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -9,6 +9,7 @@ Pure-cache helpers (get_cache, find_*) remain sync. import asyncio import logging import re +from dataclasses import dataclass, field import gspread from google.oauth2.service_account import Credentials @@ -279,6 +280,19 @@ _LINEUP_HEADER_PREFIX = "lineup" _team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...] _team_by_username: dict[str, str] = {} # normalized username -> team name _team_names: set[str] = set() # universe of all team names +_team_divider: dict[str, str] = {} # team name -> divider role name + + +@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]: @@ -321,22 +335,37 @@ def _cell(row: list, idx: int) -> str: return str(row[idx]) if 0 <= idx < len(row) else "" -def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: - """Extract {team_name: [nickname, ...]} from a tab's raw rows. +def _merged_title(row: list) -> str | None: + """Return a row's lone non-empty cell - a merged section title - else None. + + A fully blank row returns None rather than "", so blank separators between + sections do not wipe the title we are holding for the next header row. + """ + values = [str(c).strip() for c in row if str(c).strip()] + return values[0] if len(values) == 1 else None + + +def parse_team_sections(rows: list[list]) -> list[TeamSection]: + """Extract each game's block of teams from a tab's raw rows, with its title. Scans for every header row that has both a 'Team Name' and a 'Lineup...' column, then reads the rows beneath it (using that section's own column positions) until the team-name column goes blank or a new header appears. - Handles multiple stacked sections with differing layouts in one tab. + Handles multiple stacked sections with differing layouts in one tab, and + tags each with the most recent merged title row seen above it. """ - rosters: dict[str, list[str]] = {} + sections: list[TeamSection] = [] + title = "" i, n = 0, len(rows) while i < n: name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER) lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX)) if name_col is None or lineup_col is None: + if (text := _merged_title(rows[i])) is not None: + title = text i += 1 continue + rosters: dict[str, list[str]] = {} i += 1 # move past the header into the data block while i < n: team = _cell(rows[i], name_col).strip() @@ -346,9 +375,44 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: if players: rosters.setdefault(team, []).extend(players) i += 1 + if rosters: + sections.append(TeamSection(title=title, rosters=rosters)) + title = "" # consumed - do not leak it onto the next section + return sections + + +def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: + """Flatten every section in a tab into {team_name: [nickname, ...]}.""" + rosters: dict[str, list[str]] = {} + for section in parse_team_sections(rows): + for team, players in section.rosters.items(): + rosters.setdefault(team, []).extend(players) return rosters +def resolve_divider(title: str, dividers: dict[str, str] | None = None) -> str | None: + """Return the divider role name configured for a section title, if any. + + A ``TEAM_DIVIDER_`` entry matches when every underscore-separated + part of its suffix appears as a whole word in the title, so ``CS2`` matches + a CS2 section from any year while ``CS2_2026`` matches only the 2026 one. + The most specific match (most parts) wins, which lets a year-scoped entry + override a general one for the same game. + """ + if dividers is None: + dividers = config.TEAM_DIVIDERS + haystack = title.lower() + best_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]: """Invert {team: [usernames]} into {normalized username: team}. @@ -371,19 +435,25 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: def _refresh_teams_sync() -> dict[str, list[str]]: - global _team_roster, _team_by_username, _team_names + global _team_roster, _team_by_username, _team_names, _team_divider creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES) client = gspread.authorize(creds) spreadsheet = client.open_by_key(config.TEAM_SHEET_ID) rosters: dict[str, list[str]] = {} + dividers: dict[str, str] = {} for ws in spreadsheet.worksheets(): - for team, players in parse_team_rosters(ws.get_all_values()).items(): - rosters.setdefault(team, []).extend(players) + for section in parse_team_sections(ws.get_all_values()): + divider = resolve_divider(section.title) + for team, players in section.rosters.items(): + rosters.setdefault(team, []).extend(players) + if divider: + dividers[team] = divider _team_roster = rosters _team_by_username = build_username_index(rosters) _team_names = set(rosters) + _team_divider = dividers return rosters @@ -411,3 +481,12 @@ def all_team_names() -> set[str]: def get_team_rosters() -> dict[str, list[str]]: """Current {team: [usernames]} cache (mainly for diagnostics/tests).""" return _team_roster + + +def get_team_dividers() -> dict[str, 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) diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index 8fa1796..8a732d2 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -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/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | | `core/sheets.py` | Google Sheets integration (member sync) | -| `core/member_sync.py` | Birthday/member sync helpers | +| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement | ### `commands/` - one slash-command group per file @@ -148,6 +148,37 @@ 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__=""`. 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) Order top to bottom in server roles: diff --git a/strings/member.py b/strings/member.py index a604887..160c731 100644 --- a/strings/member.py +++ b/strings/member.py @@ -109,6 +109,7 @@ TEAMSYNC_UI: dict[str, str] = { "assigned": "✅ Tiimirolle antud: {count}", "removed": "➖ Tiimirolle eemaldatud: {count}", "created": "🆕 Loodud uusi tiimirolle: {roles}", + "positioned": "📍 Eraldaja alla paigutatud: {count}", "errors": "⚠️ Vead: {count}", "no_changes": "✨ Kõik tiimirollid olid juba korras.", "changes_header": "**Muudatused:**", diff --git a/tests/test_team_sync.py b/tests/test_team_sync.py index 85f781e..24fdd9e 100644 --- a/tests/test_team_sync.py +++ b/tests/test_team_sync.py @@ -87,6 +87,108 @@ def test_parse_team_rosters_ignores_non_table_content(): assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {} +# --- parse_team_sections (which game/year a block of teams came from) ------ + +def test_parse_team_sections_tags_each_block_with_its_title(): + sections = sheets.parse_team_sections(SHEET_ROWS) + assert [(s.title, sorted(s.rosters)) for s in sections] == [ + ("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]), + ("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]), + ] + + +def test_parse_team_sections_title_does_not_leak_to_untitled_block(): + rows = [ + ["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2, + ["No", "Team Name", "Lineup (nickname)"], + ["1", "GENESIS", "kapa (EST)"], + ["", "", ""], + ["No", "Team Name", "Lineup (nickname)"], # second block, no title above it + ["1", "Nameless", "someone (EST)"], + ] + titles = [s.title for s in sheets.parse_team_sections(rows)] + assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""] + + +# --- resolve_divider (section title -> configured divider role) ------------ + +DIVIDERS = { + "cs2_2026": "====== 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(): index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}