CS2 team dividers.
All checks were successful
Test & Deploy / test (push) Successful in 7s
Test & Deploy / deploy (push) Successful in 7s

This commit is contained in:
Rene Arumetsa
2026-09-03 21:36:41 +03:00
parent a4645e19c5
commit deab0f7a55
10 changed files with 422 additions and 14 deletions

View File

@@ -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

View File

@@ -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_<SUFFIX>`` entry matches when every underscore-separated
part of its suffix appears as a whole word in the title, so ``CS2`` matches
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one.
The most specific match (most parts) wins, which lets a year-scoped entry
override a general one for the same game.
"""
if dividers is None:
dividers = config.TEAM_DIVIDERS
haystack = title.lower()
best_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)