From 3c8927184b1944e735d9364d33133dab4853822c Mon Sep 17 00:00:00 2001 From: Rene Arumetsa Date: Fri, 4 Sep 2026 00:00:41 +0300 Subject: [PATCH] feat(teams): grant each participant their game's divider role Reuse the existing CS2/LoL divider roles as participant tags: sync_team_role now also adds the divider role for the member's team's game, and strips any other configured divider role (switched game / dropped out). Matched by ID against config.TEAM_DIVIDERS, so only the divider roles are ever touched. - TeamSyncResult gains divider_added / divider_removed; summary tallies both - /teamsync report and log line surface divider_assigned / divider_removed - tests: grants the game divider, swaps it on a game switch Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre --- commands/economy_team_commands.py | 9 +++++- core/member_sync.py | 50 +++++++++++++++++++++++++++++-- strings/member.py | 2 ++ tests/test_team_sync.py | 41 +++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py index 8bf88ca..77a63d2 100644 --- a/commands/economy_team_commands.py +++ b/commands/economy_team_commands.py @@ -50,12 +50,15 @@ 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, positioned=%d, errors=%d", + "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, " + "positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d", summary.scanned, summary.assigned, summary.removed, len(summary.created), summary.positioned, + summary.divider_assigned, + summary.divider_removed, len(summary.errors), ) @@ -74,6 +77,10 @@ def _format_summary(summary) -> str: 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.divider_assigned: + lines.append(S.TEAMSYNC_UI["divider_assigned"].format(count=summary.divider_assigned)) + if summary.divider_removed: + lines.append(S.TEAMSYNC_UI["divider_removed"].format(count=summary.divider_removed)) if summary.errors: lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors))) diff --git a/core/member_sync.py b/core/member_sync.py index b8c150f..282400a 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -65,11 +65,13 @@ class TeamSyncResult: added: str | None = None # team role name granted, if any removed: list[str] = field(default_factory=list) # stale team roles taken away created: str | None = None # team role name auto-created in the guild, if any + divider_added: str | None = None # game divider role granted as participant tag + divider_removed: list[str] = field(default_factory=list) # stale divider roles taken away errors: list[str] = field(default_factory=list) @property def changed(self) -> bool: - return bool(self.added or self.removed) + return bool(self.added or self.removed or self.divider_added or self.divider_removed) @dataclass @@ -80,6 +82,8 @@ class TeamSyncSummary: removed: int = 0 created: list[str] = field(default_factory=list) positioned: int = 0 # team roles moved under a divider + divider_assigned: int = 0 # members given their game divider role + divider_removed: int = 0 # stale game divider roles taken away changes: list[str] = field(default_factory=list) # human-readable per-member lines errors: list[str] = field(default_factory=list) @@ -260,9 +264,12 @@ async def sync_team_role( * grants the role for the team they're registered on (auto-creating that role in the guild when it does not exist yet); - * removes any *other* team role they still carry (left / switched teams). + * removes any *other* team role they still carry (left / switched teams); + * grants their game's divider role as a participant tag (and strips any + other configured divider role they still carry, i.e. switched game). - Only role NAMES present in the team sheet are ever added or removed, so no + Only team role NAMES present in the team sheet and the configured divider + role IDs (``config.TEAM_DIVIDERS``) are ever added or removed, so no unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches are empty and this is a no-op returning an unchanged result. """ @@ -307,6 +314,35 @@ async def sync_team_role( except discord.HTTPException as e: result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}") + # --- Participant divider role (the game's divider role doubles as a tag) --- + # Grant the divider role for the member's team's game, and strip any other + # configured divider role (switched game / dropped out). Matched by ID, so + # only the roles named in config.TEAM_DIVIDERS are ever touched. + divider_ids = set(config.TEAM_DIVIDERS.values()) + want_divider_id = sheets.get_team_dividers().get(team_name) if team_name else None + want_divider = guild.get_role(want_divider_id) if want_divider_id else None + + if want_divider is not None and want_divider not in member.roles: + try: + await member.add_roles(want_divider, reason="Team sync: mänguosaleja") + result.divider_added = want_divider.name + except discord.Forbidden: + log.debug("No permission to add divider role for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Eraldajarolli viga kasutajale {member}: {e}") + + stale_dividers = [ + r for r in member.roles if r.id in divider_ids and r.id != want_divider_id + ] + if stale_dividers: + try: + await member.remove_roles(*stale_dividers, reason="Team sync: mäng vahetus") + result.divider_removed = [r.name for r in stale_dividers] + except discord.Forbidden: + log.debug("No permission to remove divider roles for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Eraldajarolli eemaldamise viga kasutajale {member}: {e}") + return result @@ -449,12 +485,20 @@ async def sync_all_team_roles( summary.assigned += 1 if res.removed: summary.removed += len(res.removed) + if res.divider_added: + summary.divider_assigned += 1 + if res.divider_removed: + summary.divider_removed += len(res.divider_removed) if res.changed: bits: list[str] = [] if res.added: bits.append(f"+{res.added}") if res.removed: bits.append("-" + ", -".join(res.removed)) + if res.divider_added: + bits.append(f"+[{res.divider_added}]") + if res.divider_removed: + bits.append("-[" + "], -[".join(res.divider_removed) + "]") summary.changes.append(f"{member.display_name}: {', '.join(bits)}") # Placement runs after the grant/remove pass so roles created this run are diff --git a/strings/member.py b/strings/member.py index 160c731..8b73935 100644 --- a/strings/member.py +++ b/strings/member.py @@ -110,6 +110,8 @@ TEAMSYNC_UI: dict[str, str] = { "removed": "➖ Tiimirolle eemaldatud: {count}", "created": "🆕 Loodud uusi tiimirolle: {roles}", "positioned": "📍 Eraldaja alla paigutatud: {count}", + "divider_assigned": "🏷️ Mängurolle (eraldaja) antud: {count}", + "divider_removed": "➖ Mängurolle (eraldaja) eemaldatud: {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 9dc740a..642f415 100644 --- a/tests/test_team_sync.py +++ b/tests/test_team_sync.py @@ -13,6 +13,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) +import config # noqa: E402 from core import member_sync, sheets # noqa: E402 from tests.conftest import run # noqa: E402 @@ -253,6 +254,9 @@ class FakeGuild: self.created.append(name) return role + def get_role(self, rid): + return next((r for r in self.roles if r.id == rid), None) + def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch): old_team = FakeRole(1, "OldTeam") @@ -292,6 +296,43 @@ def test_sync_uses_existing_team_role(monkeypatch): assert genesis in member.roles +def test_sync_grants_game_divider_role(monkeypatch): + genesis = FakeRole(3, "GENESIS") + cs2_div = FakeRole(100, "====== CS2 2026 ======") + member = FakeMember(1, "kapa", roles=[]) + guild = FakeGuild([genesis, cs2_div]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) + monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.divider_added == "====== CS2 2026 ======" + assert cs2_div in member.roles + + +def test_sync_swaps_divider_role_on_game_switch(monkeypatch): + genesis = FakeRole(3, "GENESIS") + cs2_div = FakeRole(100, "CS2") + lol_div = FakeRole(200, "LoL") + member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team + guild = FakeGuild([genesis, cs2_div, lol_div]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) + monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.divider_added == "CS2" + assert result.divider_removed == ["LoL"] + ids = {r.id for r in member.roles} + assert 100 in ids and 200 not in ids + + def test_sync_strips_team_role_when_not_registered(monkeypatch): old_team = FakeRole(1, "OldTeam") member = FakeMember(1, "ghost", roles=[old_team])