forked from sass/tipibot
CS2 team dividers.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user