From 0834c1702230a2befcc2386e828ee26b515bfd29 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Mon, 31 Aug 2026 01:45:36 +0200 Subject: [PATCH] add achievement grants to gate increment --- NHCogs/nhmisc/README.md | 10 +- NHCogs/nhmisc/gate_increment_store.py | 238 ++++++++++++- NHCogs/nhmisc/gate_increment_views.py | 68 +++- NHCogs/nhmisc/nhmisc.py | 468 ++++++++++++++++++++++---- tests/test_gate_increment_flow.py | 450 ++++++++++++++++++++++++- tests/test_gate_increment_store.py | 227 +++++++++++++ tests/test_gatecount.py | 1 + 7 files changed, 1388 insertions(+), 74 deletions(-) diff --git a/NHCogs/nhmisc/README.md b/NHCogs/nhmisc/README.md index 5f3aa92..05a2401 100644 --- a/NHCogs/nhmisc/README.md +++ b/NHCogs/nhmisc/README.md @@ -193,9 +193,13 @@ Apps → Increment Gate roles Use the completion message's Apps menu to review a one-tier Gate increment for its author and explicitly mentioned members. The review supports up to 25 eligible users -and lets the moderator remove accidental mentions before confirming. When exactly one -user remains selected, the moderator can grant `Solo Gater` in the same role update. -Gate 6 users remain visible but cannot be selected. +and lets the moderator remove accidental mentions before confirming. Additional custom +achievements can be selected for every chosen user and start unselected. When exactly +one user remains selected, the moderator can grant `Solo Gater` in the same role update. +The public congratulations message includes the Gate and every new achievement in one +result. The selector supports up to 25 custom achievements. A selection that would not +fit in one Discord message is rejected before any roles or awards change. Gate 6 users +remain visible but cannot be selected. The action requires Manage Messages and uses a durable one-use source lock. A second message cannot reserve the same member's next Gate while an earlier increment is still diff --git a/NHCogs/nhmisc/gate_increment_store.py b/NHCogs/nhmisc/gate_increment_store.py index 3d15e1e..400d2d3 100644 --- a/NHCogs/nhmisc/gate_increment_store.py +++ b/NHCogs/nhmisc/gate_increment_store.py @@ -27,12 +27,23 @@ class GateIncrementMemberPlan: grant_solo: bool = False +@dataclass(frozen=True, slots=True) +class GateIncrementAchievementPlan: + key: str + display_name: str + role_id: int | None = None + + class GateProgressConflict(RuntimeError): def __init__(self, user_id: int) -> None: super().__init__(f"Gate progress is stale for user {user_id}") self.user_id = user_id +class AchievementDefinitionConflict(RuntimeError): + pass + + class OperationState(str, Enum): APPLYING = "applying" COMPLETED = "completed" @@ -65,6 +76,7 @@ class GateIncrementOperation: conflict_count: int = 0 result_channel_id: int | None = None result_message_id: int | None = None + published_completed_count: int = 0 @dataclass(frozen=True, slots=True) @@ -76,12 +88,16 @@ class StoredGateIncrementMember: state: MemberState failure_code: str | None grant_solo: bool = False + solo_awarded: bool = False + custom_achievement_keys: tuple[str, ...] = () + moderation_logged: bool = False @dataclass(frozen=True, slots=True) class GateIncrementSnapshot: operation: GateIncrementOperation members: tuple[StoredGateIncrementMember, ...] + custom_achievements: tuple[GateIncrementAchievementPlan, ...] = () @dataclass(frozen=True, slots=True) @@ -118,6 +134,7 @@ async def claim( key: SourceMessageKey, moderator_id: int, member_plans: tuple[GateIncrementMemberPlan, ...], + custom_achievements: tuple[GateIncrementAchievementPlan, ...] = (), ) -> ClaimResult: async with self._lock: return await asyncio.to_thread( @@ -125,6 +142,7 @@ async def claim( key, moderator_id, member_plans, + custom_achievements, ) async def get_operation( @@ -195,6 +213,16 @@ async def mark_member_conflict( key, position, MemberState.CONFLICT, failure_code ) + async def mark_moderation_logged( + self, key: SourceMessageKey, positions: tuple[int, ...] + ) -> None: + async with self._lock: + await asyncio.to_thread( + self._mark_moderation_logged_sync, + key, + positions, + ) + async def finalize_operation( self, key: SourceMessageKey ) -> GateIncrementSnapshot: @@ -211,6 +239,7 @@ async def record_result_message( token: str, channel_id: int, message_id: int, + published_completed_count: int, ) -> GateIncrementSnapshot: async with self._lock: await asyncio.to_thread( @@ -219,6 +248,7 @@ async def record_result_message( token, channel_id, message_id, + published_completed_count, ) snapshot = await self.get_operation(key) if snapshot is None: @@ -276,6 +306,7 @@ def _initialize_sync(self) -> None: conflict_count INTEGER NOT NULL DEFAULT 0, result_channel_id INTEGER, result_message_id INTEGER, + published_completed_count INTEGER NOT NULL DEFAULT 0, lease_token TEXT, publication_token TEXT, UNIQUE (guild_id, channel_id, source_message_id) @@ -290,6 +321,7 @@ def _initialize_sync(self) -> None: state TEXT NOT NULL, failure_code TEXT, grant_solo INTEGER NOT NULL DEFAULT 0, + moderation_logged INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (operation_id, position), UNIQUE (operation_id, user_id), FOREIGN KEY (operation_id) @@ -297,6 +329,19 @@ def _initialize_sync(self) -> None: ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS gate_increment_achievements ( + operation_id INTEGER NOT NULL, + achievement_key TEXT NOT NULL, + display_name TEXT NOT NULL, + role_id INTEGER, + position INTEGER NOT NULL, + PRIMARY KEY (operation_id, achievement_key), + UNIQUE (operation_id, position), + FOREIGN KEY (operation_id) + REFERENCES gate_increment_operations (operation_id) + ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS achievement_awards ( award_id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER NOT NULL, @@ -326,6 +371,52 @@ def _initialize_sync(self) -> None: DROP INDEX IF EXISTS achievement_ordinal_unique; """ ) + operation_columns = { + row["name"] + for row in connection.execute( + "PRAGMA table_info(gate_increment_operations)" + ) + } + added_publication_count = ( + "published_completed_count" not in operation_columns + ) + if added_publication_count: + connection.execute( + """ + ALTER TABLE gate_increment_operations + ADD COLUMN published_completed_count INTEGER NOT NULL DEFAULT 0 + """ + ) + member_columns = { + row["name"] + for row in connection.execute( + "PRAGMA table_info(gate_increment_members)" + ) + } + added_moderation_log_state = "moderation_logged" not in member_columns + if added_moderation_log_state: + connection.execute( + """ + ALTER TABLE gate_increment_members + ADD COLUMN moderation_logged INTEGER NOT NULL DEFAULT 0 + """ + ) + if added_publication_count: + connection.execute( + """ + UPDATE gate_increment_operations + SET published_completed_count = completed_count + WHERE result_message_id IS NOT NULL + """ + ) + if added_moderation_log_state: + connection.execute( + """ + UPDATE gate_increment_members + SET moderation_logged = 1 + WHERE state = 'completed' + """ + ) connection.execute( """ UPDATE gate_increment_operations @@ -341,6 +432,7 @@ def _claim_sync( key: SourceMessageKey, moderator_id: int, member_plans: tuple[GateIncrementMemberPlan, ...], + custom_achievements: tuple[GateIncrementAchievementPlan, ...], ) -> ClaimResult: now = datetime.now(timezone.utc).isoformat() with self._connection() as connection: @@ -357,6 +449,24 @@ def _claim_sync( ).fetchone() created = row is None if row is None: + for achievement in custom_achievements: + definition_row = connection.execute( + """ + SELECT display_name, kind, role_id, grantable + FROM achievement_definitions + WHERE guild_id = ? AND achievement_key = ? + """, + (key.guild_id, achievement.key), + ).fetchone() + if ( + definition_row is None + or definition_row["display_name"] + != achievement.display_name + or definition_row["kind"] != "boolean" + or definition_row["role_id"] != achievement.role_id + or not bool(definition_row["grantable"]) + ): + raise AchievementDefinitionConflict(achievement.key) cursor = connection.execute( """ INSERT INTO gate_increment_operations ( @@ -381,6 +491,24 @@ def _claim_sync( ), ) operation_id = int(cursor.lastrowid) + connection.executemany( + """ + INSERT INTO gate_increment_achievements ( + operation_id, achievement_key, display_name, + role_id, position + ) VALUES (?, ?, ?, ?, ?) + """, + ( + ( + operation_id, + achievement.key, + achievement.display_name, + achievement.role_id, + position, + ) + for position, achievement in enumerate(custom_achievements) + ), + ) for plan in member_plans: ordinal_rows = connection.execute( """ @@ -444,6 +572,25 @@ def _claim_sync( operation_id, ), ) + for achievement in custom_achievements: + connection.execute( + """ + INSERT OR IGNORE INTO achievement_awards ( + guild_id, user_id, achievement_key, + awarded_at, source_channel_id, + source_message_id, gate_operation_id, state + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending') + """, + ( + key.guild_id, + plan.user_id, + achievement.key, + now, + key.channel_id, + key.message_id, + operation_id, + ), + ) connection.executemany( """ INSERT INTO gate_increment_members ( @@ -502,13 +649,38 @@ def _get_operation_sync( member_rows = connection.execute( """ SELECT position, user_id, expected_gate_role_ids, - target_role_id, state, failure_code, grant_solo + target_role_id, state, failure_code, grant_solo, + moderation_logged FROM gate_increment_members WHERE operation_id = ? ORDER BY position """, (operation_row["operation_id"],), ).fetchall() + achievement_rows = connection.execute( + """ + SELECT achievement_key, display_name, role_id + FROM gate_increment_achievements + WHERE operation_id = ? + ORDER BY position + """, + (operation_row["operation_id"],), + ).fetchall() + award_rows = connection.execute( + """ + SELECT user_id, achievement_key + FROM achievement_awards + WHERE gate_operation_id = ? AND ordinal IS NULL + ORDER BY award_id + """, + (operation_row["operation_id"],), + ).fetchall() + + award_keys_by_user: dict[int, list[str]] = {} + for row in award_rows: + award_keys_by_user.setdefault(int(row["user_id"]), []).append( + str(row["achievement_key"]) + ) return GateIncrementSnapshot( operation=self._operation_from_row(operation_row), @@ -524,9 +696,31 @@ def _get_operation_sync( state=MemberState(row["state"]), failure_code=row["failure_code"], grant_solo=bool(row["grant_solo"]), + solo_awarded=( + "solo_gater" + in award_keys_by_user.get(int(row["user_id"]), ()) + ) + if row["user_id"] is not None + else False, + custom_achievement_keys=tuple( + key + for key in award_keys_by_user.get(int(row["user_id"]), ()) + if key != "solo_gater" + ) + if row["user_id"] is not None + else (), + moderation_logged=bool(row["moderation_logged"]), ) for row in member_rows ), + custom_achievements=tuple( + GateIncrementAchievementPlan( + key=str(row["achievement_key"]), + display_name=str(row["display_name"]), + role_id=row["role_id"], + ) + for row in achievement_rows + ), ) def _list_interrupted_operations_sync( @@ -536,8 +730,16 @@ def _list_interrupted_operations_sync( rows = connection.execute( """ SELECT guild_id, channel_id, source_message_id - FROM gate_increment_operations - WHERE state = 'applying' + FROM gate_increment_operations AS operations + WHERE state IN ('applying', 'partial') + OR published_completed_count < completed_count + OR EXISTS ( + SELECT 1 + FROM gate_increment_members AS members + WHERE members.operation_id = operations.operation_id + AND members.state = 'completed' + AND members.moderation_logged = 0 + ) ORDER BY operation_id """ ).fetchall() @@ -609,7 +811,7 @@ def _acquire_publication_lease_sync( WHERE guild_id = ? AND channel_id = ? AND source_message_id = ? AND state IN ('completed', 'partial') - AND result_message_id IS NULL + AND completed_count > published_completed_count AND publication_token IS NULL """, ( @@ -722,6 +924,30 @@ def _mark_member_state_sync( ) connection.commit() + def _mark_moderation_logged_sync( + self, key: SourceMessageKey, positions: tuple[int, ...] + ) -> None: + if not positions: + return + with self._connection() as connection: + connection.execute("BEGIN IMMEDIATE") + placeholders = ",".join("?" for _ in positions) + connection.execute( + f""" + UPDATE gate_increment_members + SET moderation_logged = 1 + WHERE operation_id = ( + SELECT operation_id + FROM gate_increment_operations + WHERE guild_id = ? AND channel_id = ? + AND source_message_id = ? + ) AND position IN ({placeholders}) + AND state = 'completed' + """, + (key.guild_id, key.channel_id, key.message_id, *positions), + ) + connection.commit() + def _finalize_operation_sync(self, key: SourceMessageKey) -> None: with self._connection() as connection: operation_row = connection.execute( @@ -779,12 +1005,14 @@ def _record_result_message_sync( token: str, channel_id: int, message_id: int, + published_completed_count: int, ) -> None: with self._connection() as connection: cursor = connection.execute( """ UPDATE gate_increment_operations SET result_channel_id = ?, result_message_id = ?, + published_completed_count = ?, publication_token = NULL, updated_at = ? WHERE guild_id = ? AND channel_id = ? AND source_message_id = ? @@ -794,6 +1022,7 @@ def _record_result_message_sync( ( channel_id, message_id, + published_completed_count, datetime.now(timezone.utc).isoformat(), key.guild_id, key.channel_id, @@ -856,4 +1085,5 @@ def _operation_from_row(row: sqlite3.Row) -> GateIncrementOperation: conflict_count=int(row["conflict_count"]), result_channel_id=row["result_channel_id"], result_message_id=row["result_message_id"], + published_completed_count=int(row["published_completed_count"]), ) diff --git a/NHCogs/nhmisc/gate_increment_views.py b/NHCogs/nhmisc/gate_increment_views.py index be0f0b1..13110e2 100644 --- a/NHCogs/nhmisc/gate_increment_views.py +++ b/NHCogs/nhmisc/gate_increment_views.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: from .nhmisc import NHMisc - class GateIncrementReviewView(discord.ui.View): def __init__( self, @@ -16,6 +15,7 @@ def __init__( opener_id: int, candidates: tuple[Any, ...], *, + custom_achievements: tuple[Any, ...] = (), ephemeral: bool, ) -> None: super().__init__(timeout=300) @@ -25,6 +25,8 @@ def __init__( self.ephemeral = ephemeral self.message: discord.Message | None = None self.candidates = candidates + self.custom_achievements = custom_achievements + self.selected_custom_achievement_keys: set[str] = set() self.selected_user_ids = { candidate.user_id for candidate in candidates @@ -32,6 +34,7 @@ def __init__( } self.solo_gater_enabled = False self._configure_select() + self._configure_achievement_select() self._configure_solo_toggle() @property @@ -57,6 +60,12 @@ def replace_candidates(self, candidates: tuple[Any, ...]) -> None: self._configure_select() self._configure_solo_toggle() + def replace_custom_achievements(self, achievements: tuple[Any, ...]) -> None: + self.custom_achievements = achievements + live_keys = {achievement.key for achievement in achievements} + self.selected_custom_achievement_keys.intersection_update(live_keys) + self._configure_achievement_select() + def render_embed(self, *, notice: str | None = None) -> discord.Embed: selectable_count = sum( candidate.target_role_id is not None for candidate in self.candidates @@ -105,6 +114,14 @@ def render_embed(self, *, notice: str | None = None) -> discord.Embed: lines.extend(("", "Solo Gater: already assigned")) elif self.solo_gater_enabled: lines.extend(("", "Solo Gater: will be assigned")) + if self.selected_custom_achievement_keys: + lines.extend( + ( + "", + "Achievements: " + f"{len(self.selected_custom_achievement_keys)} selected", + ) + ) if notice: lines.extend(("", notice)) return discord.Embed( @@ -160,10 +177,28 @@ async def candidate_select( allowed_mentions=discord.AllowedMentions.none(), ) + @discord.ui.select( + placeholder="Choose additional achievements", + min_values=0, + max_values=1, + row=1, + ) + async def achievement_select( + self, + interaction: discord.Interaction, + select: discord.ui.Select, + ) -> None: + self.selected_custom_achievement_keys = set(select.values) + await interaction.response.edit_message( + embed=self.render_embed(), + view=self, + allowed_mentions=discord.AllowedMentions.none(), + ) + @discord.ui.button( label="Confirm increment", style=discord.ButtonStyle.green, - row=1, + row=2, ) async def confirm( self, @@ -175,7 +210,7 @@ async def confirm( @discord.ui.button( label="Refresh", style=discord.ButtonStyle.secondary, - row=1, + row=2, ) async def refresh( self, @@ -187,7 +222,7 @@ async def refresh( @discord.ui.button( label="☐ Solo Gater", style=discord.ButtonStyle.secondary, - row=2, + row=3, ) async def solo_gater( self, @@ -205,7 +240,7 @@ async def solo_gater( @discord.ui.button( label="Cancel", style=discord.ButtonStyle.danger, - row=1, + row=2, ) async def cancel( self, @@ -290,6 +325,29 @@ def _configure_solo_toggle(self) -> None: else discord.ButtonStyle.secondary ) + def _configure_achievement_select(self) -> None: + options = [ + discord.SelectOption( + label=achievement.display_name[:100], + value=achievement.key, + default=achievement.key in self.selected_custom_achievement_keys, + ) + for achievement in self.custom_achievements + ] + if options: + self.achievement_select.options = options + self.achievement_select.max_values = len(options) + self.achievement_select.disabled = False + return + self.achievement_select.options = [ + discord.SelectOption( + label="No additional achievements available", + value="none", + ) + ] + self.achievement_select.max_values = 1 + self.achievement_select.disabled = True + def _disable_controls(self) -> None: for child in self.children: child.disabled = True diff --git a/NHCogs/nhmisc/nhmisc.py b/NHCogs/nhmisc/nhmisc.py index 5008031..bb653bc 100644 --- a/NHCogs/nhmisc/nhmisc.py +++ b/NHCogs/nhmisc/nhmisc.py @@ -22,6 +22,7 @@ from .achievement_definitions import ( SOLO_GATER_DEFINITION, SOLO_GATER_KEY, + STARGATE_COMPLETED_KEY, ) from .achievement_store import ( AchievementDefinition, @@ -55,6 +56,8 @@ from .discord_links import MESSAGE_LINK_PATTERN from .forum_autopin import ForumAutopinService from .gate_increment_store import ( + AchievementDefinitionConflict, + GateIncrementAchievementPlan, GateIncrementMemberPlan, GateIncrementSnapshot, GateIncrementStore, @@ -124,6 +127,7 @@ def _parse_sticky_db_decision(content: str) -> tuple[str, int | None, str]: return command.lower(), role_id, argument.strip() DEFAULT_CHATCHART_USER_COUNT = 10 +DISCORD_MESSAGE_CONTENT_LIMIT = 2_000 MAX_CHATCHART_USER_COUNT = 20 CHATCHART_SERIES_COLORS = SERIES_COLORS CHATCHART_OTHER_COLOR = OTHER_COLOR @@ -366,6 +370,92 @@ def _build_gate_increment_member_plans( return tuple(plans) +def _gate_increment_custom_achievement_definitions( + definitions, +) -> tuple[AchievementDefinition, ...]: + eligible = tuple( + definition + for definition in definitions + if definition.grantable + and definition.key not in {STARGATE_COMPLETED_KEY, SOLO_GATER_KEY} + ) + if len(eligible) > 25: + raise commands.UserFeedbackCheckFailure( + "Gate increment supports at most 25 custom achievements" + ) + return eligible + + +def _gate_increment_custom_award_labels( + snapshot: GateIncrementSnapshot, + member, +) -> tuple[str, ...]: + definitions_by_key = { + achievement.key: achievement + for achievement in snapshot.custom_achievements + } + labels = [] + for key in member.custom_achievement_keys: + achievement = definitions_by_key.get(key) + if achievement is None: + continue + labels.append( + f"<@&{achievement.role_id}>" + if achievement.role_id is not None + else achievement.display_name + ) + return tuple(labels) + + +def _validate_gate_increment_output_limits( + source_message, + moderator_id: int, + plans: tuple[GateIncrementMemberPlan, ...], + achievements: tuple[GateIncrementAchievementPlan, ...], + owned_keys_by_user: dict[int, set[str]] | None = None, +) -> None: + owned_keys_by_user = owned_keys_by_user or {} + public_lines = [] + log_lines = [] + for plan in plans: + achievement_labels = tuple( + f"<@&{achievement.role_id}>" + if achievement.role_id is not None + else achievement.display_name + for achievement in achievements + if achievement.key not in owned_keys_by_user.get(plan.user_id, set()) + ) + awards = [f"<@&{plan.target_role_id}>"] + log_awards = [] + if plan.grant_solo: + awards.append(f"<@&{SINGLEPLAYER_GATE_COMPLETED_ROLE_ID}>") + log_awards.append("Solo Gater") + awards.extend(achievement_labels) + log_awards.extend(achievement_labels) + public_lines.append(f"<@{plan.user_id}> " + " ".join(awards)) + gate_number = GATE_TIER_ROLE_IDS.index(plan.target_role_id) + 1 + log_line = f"<@{plan.user_id}> Gate {gate_number}" + if log_awards: + log_line += " + " + " + ".join(log_awards) + log_lines.append(log_line) + public_content = "🎉 **Congratulations!**\n" + "\n".join(public_lines) + source_url = ( + "https://discord.com/channels/" + f"{source_message.guild.id}/{source_message.channel.id}/{source_message.id}" + ) + log_content = ( + "Gate incremented\n" + f"Moderator: <@{moderator_id}>\n" + f"Members: {', '.join(log_lines)}\n" + f"Source: {source_url}" + ) + if max(len(public_content), len(log_content)) > DISCORD_MESSAGE_CONTENT_LIMIT: + raise commands.UserFeedbackCheckFailure( + "The selected Gate increment is too large for one Discord message. " + "Select fewer users or achievements" + ) + + def _validate_gate_increment_configuration(guild) -> tuple: bot_member = guild.me permissions = getattr(bot_member, "guild_permissions", None) @@ -4760,6 +4850,9 @@ async def _create_gate_increment_review( _validate_gate_increment_configuration(source_message.guild) candidates = await self._fetch_gate_increment_candidates(source_message) self._validate_gate_increment_candidate_count(candidates) + definitions = _gate_increment_custom_achievement_definitions( + await self._achievement_store.list_definitions(source_message.guild.id) + ) from .gate_increment_views import GateIncrementReviewView return GateIncrementReviewView( @@ -4767,6 +4860,7 @@ async def _create_gate_increment_review( source_message, opener_id, candidates, + custom_achievements=definitions, ephemeral=ephemeral, ) @@ -4823,7 +4917,12 @@ async def _retry_gate_increment_publication( snapshot: GateIncrementSnapshot, ) -> GateIncrementSnapshot: operation = snapshot.operation - if operation.completed_count and operation.result_message_id is None: + if operation.completed_count: + await self._publish_gate_increment_moderation_log( + source_message, + operation.moderator_id or self.bot.user.id, + snapshot, + ) await self._publish_gate_increment_result(source_message, snapshot) refreshed = await self._gate_increment_store.get_operation(operation.key) if refreshed is not None: @@ -4847,6 +4946,11 @@ async def _resume_gate_increment_review(self, interaction, view) -> None: self._format_gate_increment_operation(snapshot), ) return + await self._publish_gate_increment_moderation_log( + source_message, + snapshot.operation.moderator_id or interaction.user.id, + snapshot, + ) published = await self._publish_gate_increment_result( source_message, snapshot, @@ -4882,6 +4986,9 @@ async def _refresh_gate_increment_review(self, interaction, view) -> None: _validate_gate_increment_configuration(source_message.guild) candidates = await self._fetch_gate_increment_candidates(source_message) self._validate_gate_increment_candidate_count(candidates) + definitions = _gate_increment_custom_achievement_definitions( + await self._achievement_store.list_definitions(source_message.guild.id) + ) except commands.UserFeedbackCheckFailure as error: await interaction.edit_original_response( content=None, @@ -4900,6 +5007,7 @@ async def _refresh_gate_increment_review(self, interaction, view) -> None: return view.source_message = source_message view.replace_candidates(candidates) + view.replace_custom_achievements(definitions) await interaction.edit_original_response( content=None, embed=view.render_embed(), @@ -4917,6 +5025,36 @@ async def _prepare_gate_increment_confirmation(self, interaction, view): source_message ) self._validate_gate_increment_candidate_count(live_candidates) + live_achievements = _gate_increment_custom_achievement_definitions( + await self._achievement_store.list_definitions( + source_message.guild.id + ) + ) + profiles = await asyncio.gather( + *( + self._achievement_store.get_profile( + source_message.guild.id, + candidate.user_id, + ) + for candidate in live_candidates + if candidate.user_id in view.selected_user_ids + and view.selected_custom_achievement_keys + ) + ) + selected_user_ids = tuple( + candidate.user_id + for candidate in live_candidates + if candidate.user_id in view.selected_user_ids + and view.selected_custom_achievement_keys + ) + owned_keys_by_user = { + user_id: set(profile.boolean_keys) + for user_id, profile in zip( + selected_user_ids, + profiles, + strict=True, + ) + } await self._require_private_moderation_log_channel(source_message.guild) except commands.UserFeedbackCheckFailure as error: await interaction.edit_original_response( @@ -4934,10 +5072,14 @@ async def _prepare_gate_increment_confirmation(self, interaction, view): public_defer=False, ) return None - return source_message, live_candidates + return ( + source_message, + live_candidates, + live_achievements, + owned_keys_by_user, + ) - async def _confirm_gate_increment_review(self, interaction, view) -> None: - await interaction.response.defer() + async def _prepare_gate_increment_claim(self, interaction, view): if not await self._achievement_store.is_bootstrapped( view.source_message.guild.id ): @@ -4949,22 +5091,32 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: view=view, allowed_mentions=discord.AllowedMentions.none(), ) - return + return None if not view.selected_user_ids: await interaction.edit_original_response( embed=view.render_embed(notice="Select at least one user"), view=view, allowed_mentions=discord.AllowedMentions.none(), ) - return + return None prepared = await self._prepare_gate_increment_confirmation(interaction, view) if prepared is None: - return - source_message, live_candidates = prepared - - if self._gate_increment_review_is_stale(view, live_candidates): + return None + ( + source_message, + live_candidates, + live_achievements, + owned_keys_by_user, + ) = prepared + + if self._gate_increment_review_is_stale( + view, live_candidates + ) or self._gate_increment_achievement_selection_is_stale( + view, live_achievements + ): view.source_message = source_message view.replace_candidates(live_candidates) + view.replace_custom_achievements(live_achievements) await interaction.edit_original_response( content=None, embed=view.render_embed( @@ -4976,7 +5128,7 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: view=view, allowed_mentions=discord.AllowedMentions.none(), ) - return + return None selected_candidates = tuple( candidate @@ -4990,12 +5142,46 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: view.solo_gater_enabled and len(selected_candidates) == 1 ), ) + selected_achievements = tuple( + GateIncrementAchievementPlan( + definition.key, + definition.display_name, + definition.role_id, + ) + for definition in live_achievements + if definition.key in view.selected_custom_achievement_keys + ) + try: + _validate_gate_increment_output_limits( + source_message, + interaction.user.id, + plans, + selected_achievements, + owned_keys_by_user, + ) + except commands.UserFeedbackCheckFailure as error: + await interaction.edit_original_response( + content=None, + embed=view.render_embed(notice=str(error)), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + return None + return source_message, plans, selected_achievements + + async def _confirm_gate_increment_review(self, interaction, view) -> None: + await interaction.response.defer() + prepared = await self._prepare_gate_increment_claim(interaction, view) + if prepared is None: + return + source_message, plans, selected_achievements = prepared key = self._gate_increment_key(source_message) try: claim = await self._gate_increment_store.claim( key, interaction.user.id, plans, + selected_achievements, ) except GateProgressConflict: await interaction.edit_original_response( @@ -5010,6 +5196,19 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: allowed_mentions=discord.AllowedMentions.none(), ) return + except AchievementDefinitionConflict: + await interaction.edit_original_response( + content=None, + embed=view.render_embed( + notice=( + "A selected achievement changed. Refresh and review " + "the plan again" + ) + ), + view=view, + allowed_mentions=discord.AllowedMentions.none(), + ) + return if not claim.created: existing = await self._gate_increment_store.get_operation(key) await self._finish_gate_increment_review( @@ -5028,31 +5227,11 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: for member_plan in snapshot.members if member_plan.state is MemberState.COMPLETED ) - moderation_log_delivered = True - if completed_members: - increments = ", ".join( - f"<@{member_plan.user_id}> Gate " - f"{GATE_TIER_ROLE_IDS.index(member_plan.target_role_id) + 1}" - + (" + Solo Gater" if member_plan.grant_solo else "") - for member_plan in completed_members - if member_plan.target_role_id in GATE_TIER_ROLE_IDS - ) - source_url = ( - "https://discord.com/channels/" - f"{source_message.guild.id}/{source_message.channel.id}/" - f"{source_message.id}" - ) - try: - moderation_log_delivered = await self._send_moderation_log( - source_message.guild, - "Gate incremented\n" - f"Moderator: <@{interaction.user.id}>\n" - f"Members: {increments}\n" - f"Source: {source_url}", - log_failure=False, - ) - except Exception: - moderation_log_delivered = False + moderation_log_delivered = await self._publish_gate_increment_moderation_log( + source_message, + interaction.user.id, + snapshot, + ) skipped_members = len(snapshot.members) - len(completed_members) if skipped_members: try: @@ -5083,6 +5262,59 @@ async def _confirm_gate_increment_review(self, interaction, view) -> None: ), ) + async def _publish_gate_increment_moderation_log( + self, + source_message, + moderator_id: int, + snapshot: GateIncrementSnapshot, + ) -> bool: + completed_members = tuple( + member + for member in snapshot.members + if member.state is MemberState.COMPLETED + and not member.moderation_logged + ) + if not completed_members: + return True + increment_lines = [] + for member_plan in completed_members: + if member_plan.target_role_id not in GATE_TIER_ROLE_IDS: + continue + awards = [] + if member_plan.solo_awarded: + awards.append("Solo Gater") + awards.extend(_gate_increment_custom_award_labels(snapshot, member_plan)) + increment = ( + f"<@{member_plan.user_id}> Gate " + f"{GATE_TIER_ROLE_IDS.index(member_plan.target_role_id) + 1}" + ) + if awards: + increment += " + " + " + ".join(awards) + increment_lines.append(increment) + source_url = ( + "https://discord.com/channels/" + f"{source_message.guild.id}/{source_message.channel.id}/" + f"{source_message.id}" + ) + try: + delivered = await self._send_moderation_log( + source_message.guild, + "Gate incremented\n" + f"Moderator: <@{moderator_id}>\n" + f"Members: {', '.join(increment_lines)}\n" + f"Source: {source_url}", + log_failure=False, + ) + except Exception: + return False + if not delivered: + return False + await self._gate_increment_store.mark_moderation_logged( + snapshot.operation.key, + tuple(member.position for member in completed_members), + ) + return True + async def _fetch_gate_increment_candidates( self, source_message: discord.Message ) -> tuple[GateIncrementCandidate, ...]: @@ -5180,6 +5412,31 @@ def _gate_increment_review_is_stale(view, live_candidates) -> bool: if candidate.user_id in view.selected_user_ids ) + @staticmethod + def _gate_increment_achievement_selection_is_stale( + view, live_achievements + ) -> bool: + selected_keys = view.selected_custom_achievement_keys + preview_by_key = { + achievement.key: achievement + for achievement in view.custom_achievements + if achievement.key in selected_keys + } + live_by_key = { + achievement.key: achievement + for achievement in live_achievements + if achievement.key in selected_keys + } + if preview_by_key.keys() != live_by_key.keys(): + return True + return any( + ( + preview_by_key[key].display_name != live_by_key[key].display_name + or preview_by_key[key].role_id != live_by_key[key].role_id + ) + for key in selected_keys + ) + async def _execute_gate_increment_operation( self, source_message: discord.Message, @@ -5206,6 +5463,7 @@ async def _execute_gate_increment_operation( key, member_plan, moderator_id, + snapshot.custom_achievements, ) return await self._gate_increment_store.finalize_operation(key) except Exception: @@ -5218,6 +5476,7 @@ async def _recover_gate_increment_member( key: SourceMessageKey, member_plan, moderator_id: int, + custom_achievements: tuple[GateIncrementAchievementPlan, ...] = (), ) -> None: await self._gate_increment_store.mark_member_in_progress( key, member_plan.position @@ -5237,7 +5496,19 @@ async def _recover_gate_increment_member( member_plan, transition.current_role_ids, ) - if recovery is RecoveryAction.COMPLETE: + custom_keys = set(member_plan.custom_achievement_keys) + extra_role_ids = tuple( + achievement.role_id + for achievement in custom_achievements + if achievement.key in custom_keys and achievement.role_id is not None + ) + required_role_ids = set(extra_role_ids) + if member_plan.solo_awarded: + required_role_ids.add(SINGLEPLAYER_GATE_COMPLETED_ROLE_ID) + current_role_ids = {role.id for role in member.roles} + if recovery is RecoveryAction.COMPLETE and required_role_ids.issubset( + current_role_ids + ): completed = True elif recovery is RecoveryAction.CONFLICT: conflict_code = "roles_changed" @@ -5248,7 +5519,8 @@ async def _recover_gate_increment_member( member_plan.target_role_id, key, moderator_id, - grant_solo=member_plan.grant_solo, + grant_solo=member_plan.solo_awarded, + extra_role_ids=extra_role_ids, ) completed = failure_code is None except discord.NotFound: @@ -5280,6 +5552,7 @@ async def _apply_fixed_gate_target( moderator_id: int, *, grant_solo: bool = False, + extra_role_ids: tuple[int, ...] = (), ) -> str | None: if member.top_role.position >= guild.me.top_role.position: return "hierarchy" @@ -5289,6 +5562,9 @@ async def _apply_fixed_gate_target( ) if grant_solo and SINGLEPLAYER_GATE_COMPLETED_ROLE_ID not in desired_role_ids: desired_role_ids = (*desired_role_ids, SINGLEPLAYER_GATE_COMPLETED_ROLE_ID) + for role_id in extra_role_ids: + if role_id not in desired_role_ids: + desired_role_ids = (*desired_role_ids, role_id) desired_roles = [ guild.get_role(role_id) for role_id in desired_role_ids @@ -5310,7 +5586,10 @@ async def _publish_gate_increment_result( source_message: discord.Message, snapshot: GateIncrementSnapshot, ) -> bool: - if snapshot.operation.result_message_id is not None: + if ( + snapshot.operation.published_completed_count + >= snapshot.operation.completed_count + ): return True publication_token = uuid4().hex if not await self._gate_increment_store.acquire_publication_lease( @@ -5318,33 +5597,102 @@ async def _publish_gate_increment_result( publication_token, ): return True - lines = [ - f"<@{member.user_id}> <@&{member.target_role_id}>" - + ( - f" <@&{SINGLEPLAYER_GATE_COMPLETED_ROLE_ID}>" - if member.grant_solo - else "" + refreshed = await self._gate_increment_store.get_operation( + snapshot.operation.key + ) + if refreshed is None: + await self._gate_increment_store.release_publication_lease( + snapshot.operation.key, + publication_token, ) - for member in snapshot.members - if member.state is MemberState.COMPLETED - and member.user_id is not None - and member.target_role_id is not None - ] + raise RuntimeError("Gate increment operation disappeared") + snapshot = refreshed + lines = [] + recipient_ids = [] + for member in snapshot.members: + if ( + member.state is not MemberState.COMPLETED + or member.user_id is None + or member.target_role_id is None + ): + continue + awards = [f"<@&{member.target_role_id}>"] + if member.solo_awarded: + awards.append(f"<@&{SINGLEPLAYER_GATE_COMPLETED_ROLE_ID}>") + awards.extend(_gate_increment_custom_award_labels(snapshot, member)) + lines.append(f"<@{member.user_id}> " + " ".join(awards)) + recipient_ids.append(member.user_id) if not lines: await self._gate_increment_store.release_publication_lease( snapshot.operation.key, publication_token, ) return True + content = "🎉 **Congratulations!**\n" + "\n".join(lines) + if len(content) > DISCORD_MESSAGE_CONTENT_LIMIT: + await self._gate_increment_store.release_publication_lease( + snapshot.operation.key, + publication_token, + ) + log.error( + "Gate increment result exceeds Discord limit for message %s", + source_message.id, + ) + return False + allowed_mentions = discord.AllowedMentions( + users=[discord.Object(id=user_id) for user_id in recipient_ids], + roles=False, + everyone=False, + replied_user=False, + ) + return await self._deliver_gate_increment_result( + source_message, + snapshot, + publication_token, + content, + allowed_mentions, + ) + + async def _deliver_gate_increment_result( + self, + source_message, + snapshot: GateIncrementSnapshot, + publication_token: str, + content: str, + allowed_mentions, + ) -> bool: + if snapshot.operation.result_message_id is not None: + try: + result_message = source_message.channel.get_partial_message( + snapshot.operation.result_message_id + ) + await result_message.edit( + content=content, + allowed_mentions=allowed_mentions, + ) + except discord.HTTPException: + await self._gate_increment_store.release_publication_lease( + snapshot.operation.key, + publication_token, + ) + log.exception( + "Failed to update Gate increment result for message %s", + source_message.id, + ) + return False + await self._gate_increment_store.record_result_message( + snapshot.operation.key, + publication_token, + snapshot.operation.result_channel_id or source_message.channel.id, + snapshot.operation.result_message_id, + snapshot.operation.completed_count, + ) + return True try: result_message = await source_message.reply( - "🎉 **Congratulations!**\n" + "\n".join(lines), - allowed_mentions=discord.AllowedMentions( - users=True, - roles=False, - everyone=False, - replied_user=False, - ), + content, + allowed_mentions=allowed_mentions, + nonce=f"gate-{snapshot.operation.operation_id}", ) except discord.HTTPException: await self._gate_increment_store.release_publication_lease( @@ -5361,6 +5709,7 @@ async def _publish_gate_increment_result( publication_token, result_message.channel.id, result_message.id, + snapshot.operation.completed_count, ) return True @@ -5458,6 +5807,11 @@ async def _recover_interrupted_gate_increments(self) -> None: source_message, snapshot.operation.moderator_id or self.bot.user.id, ) + await self._publish_gate_increment_moderation_log( + source_message, + recovered.operation.moderator_id or self.bot.user.id, + recovered, + ) await self._publish_gate_increment_result( source_message, recovered, diff --git a/tests/test_gate_increment_flow.py b/tests/test_gate_increment_flow.py index dbd8207..6a2653c 100644 --- a/tests/test_gate_increment_flow.py +++ b/tests/test_gate_increment_flow.py @@ -4,6 +4,7 @@ import contextlib import importlib.util import json +import sqlite3 import sys import unittest from pathlib import Path @@ -198,6 +199,151 @@ def test_review_hides_deselected_users_and_warns_when_increment_fills_gap(self): description, ) + def test_custom_achievements_start_unselected_and_survive_recipient_changes(self): + views = _load_gate_increment_views() + first = nhmisc.GateIncrementCandidate( + 1, "one", (), None, nhmisc.GATE_TIER_ROLE_IDS[0] + ) + second = nhmisc.GateIncrementCandidate( + 2, "two", (), None, nhmisc.GATE_TIER_ROLE_IDS[0] + ) + achievement = SimpleNamespace( + key="garden_of_grind", + display_name="Garden of Grind", + role_id=50, + ) + view = views.GateIncrementReviewView( + SimpleNamespace(), + SimpleNamespace(jump_url="https://example.invalid/source"), + 42, + (first, second), + custom_achievements=(achievement,), + ephemeral=True, + ) + + self.assertEqual(view.selected_custom_achievement_keys, set()) + self.assertFalse(view.achievement_select.options[0].default) + view.selected_custom_achievement_keys = {"garden_of_grind"} + + view.replace_candidates((first,)) + + self.assertEqual( + view.selected_custom_achievement_keys, + {"garden_of_grind"}, + ) + self.assertIn("Achievements: 1 selected", view.render_embed().description) + + def test_system_achievements_are_excluded_and_selector_limit_is_explicit(self): + definitions = tuple( + SimpleNamespace( + key=key, + display_name=key, + role_id=None, + grantable=True, + ) + for key in ( + "stargate_completed", + "solo_gater", + "garden_of_grind", + ) + ) + + eligible = nhmisc._gate_increment_custom_achievement_definitions(definitions) + + self.assertEqual( + tuple(definition.key for definition in eligible), + ("garden_of_grind",), + ) + too_many = tuple( + SimpleNamespace( + key=f"custom_{index}", + display_name=f"Custom {index}", + role_id=None, + grantable=True, + ) + for index in range(26) + ) + with self.assertRaisesRegex( + nhmisc.commands.UserFeedbackCheckFailure, + "at most 25", + ): + nhmisc._gate_increment_custom_achievement_definitions(too_many) + + def test_selected_definition_drift_requires_reconfirmation(self): + preview = SimpleNamespace( + key="garden_of_grind", + display_name="Garden of Grind", + role_id=50, + ) + view = SimpleNamespace( + custom_achievements=(preview,), + selected_custom_achievement_keys={"garden_of_grind"}, + ) + changed = SimpleNamespace( + key="garden_of_grind", + display_name="Garden of Grind", + role_id=51, + ) + + self.assertTrue( + nhmisc.NHMisc._gate_increment_achievement_selection_is_stale( + view, (changed,) + ) + ) + self.assertFalse( + nhmisc.NHMisc._gate_increment_achievement_selection_is_stale( + view, (preview,) + ) + ) + + def test_oversized_combined_result_is_rejected_before_claim(self): + plans = tuple( + nhmisc.GateIncrementMemberPlan( + 100000000000000000 + index, + (), + nhmisc.GATE_TIER_ROLE_IDS[0], + ) + for index in range(4) + ) + achievements = tuple( + nhmisc.GateIncrementAchievementPlan( + f"custom_{index}", + f"Custom {index}", + 200000000000000000 + index, + ) + for index in range(25) + ) + + with self.assertRaisesRegex( + nhmisc.commands.UserFeedbackCheckFailure, + "too large for one Discord message", + ): + nhmisc._validate_gate_increment_output_limits( + SimpleNamespace( + guild=SimpleNamespace(id=1), + channel=SimpleNamespace(id=2), + id=3, + ), + 4, + plans, + achievements, + ) + owned_keys = {achievement.key for achievement in achievements} + try: + nhmisc._validate_gate_increment_output_limits( + SimpleNamespace( + guild=SimpleNamespace(id=1), + channel=SimpleNamespace(id=2), + id=3, + ), + 4, + plans, + achievements, + {plan.user_id: owned_keys for plan in plans}, + ) + except nhmisc.commands.UserFeedbackCheckFailure as error: + self.fail(f"Already-owned achievements blocked Gate increment: {error}") + class GateIncrementDatabasePlanningTests(unittest.IsolatedAsyncioTestCase): async def test_candidate_uses_active_count_for_role_and_lowest_gap_for_ordinal(self): @@ -252,6 +398,35 @@ async def asyncSetUp(self): ) await self.store.initialize() + def _insert_definitions(self, guild_id, *definitions): + connection = sqlite3.connect(self.store._path) + try: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS achievement_definitions ( + guild_id INTEGER NOT NULL, + achievement_key TEXT NOT NULL, + display_name TEXT NOT NULL, + kind TEXT NOT NULL, + role_id INTEGER, + grantable INTEGER NOT NULL, + revocable INTEGER NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (guild_id, achievement_key) + ) + """ + ) + connection.executemany( + "INSERT INTO achievement_definitions VALUES (?, ?, ?, 'boolean', ?, 1, 1, ?)", + ( + (guild_id, key, name, role_id, position) + for position, (key, name, role_id) in enumerate(definitions) + ), + ) + connection.commit() + finally: + connection.close() + async def test_claimed_member_is_edited_once_with_fixed_target(self): role_by_id = { role_id: SimpleNamespace(id=role_id, managed=False, position=position) @@ -401,6 +576,75 @@ async def test_solo_selection_adds_solo_role_in_same_member_edit(self): {unrelated_role.id, gate_role.id, solo_role.id}, ) + async def test_custom_achievement_roles_are_applied_in_the_gate_edit(self): + default_role = SimpleNamespace(id=0, position=0, managed=False) + unrelated_role = SimpleNamespace(id=50, position=1, managed=False) + gate_role = SimpleNamespace( + id=nhmisc.GATE_TIER_ROLE_IDS[0], position=2, managed=False + ) + achievement_role = SimpleNamespace(id=51, position=3, managed=False) + self._insert_definitions( + 70, + ("garden_of_grind", "Garden of Grind", achievement_role.id), + ("flawless", "Flawless", None), + ) + roles = { + role.id: role + for role in (default_role, unrelated_role, gate_role, achievement_role) + } + for position, role_id in enumerate(nhmisc.GATE_TIER_ROLE_IDS, start=2): + roles.setdefault( + role_id, + SimpleNamespace(id=role_id, position=position, managed=False), + ) + member = _EditableMember( + 60, + (default_role, unrelated_role), + top_role=unrelated_role, + ) + guild = SimpleNamespace( + id=70, + me=SimpleNamespace( + guild_permissions=SimpleNamespace(manage_roles=True), + top_role=SimpleNamespace(position=100), + ), + default_role=default_role, + get_role=roles.get, + fetch_member=lambda _user_id: _async_value(member), + ) + source = SimpleNamespace( + id=80, + guild=guild, + channel=SimpleNamespace(id=90), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._gate_increment_store = self.store + key = cog._gate_increment_key(source) + await self.store.claim( + key, + 100, + (nhmisc.GateIncrementMemberPlan(member.id, (), gate_role.id),), + ( + nhmisc.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", achievement_role.id + ), + nhmisc.GateIncrementAchievementPlan("flawless", "Flawless"), + ), + ) + + result = await cog._execute_gate_increment_operation(source, 100) + + self.assertEqual(result.operation.state, nhmisc.OperationState.COMPLETED) + self.assertEqual(len(member.edits), 1) + self.assertEqual( + {role.id for role in member.edits[0][0]}, + {unrelated_role.id, gate_role.id, achievement_role.id}, + ) + self.assertEqual( + result.members[0].custom_achievement_keys, + ("garden_of_grind", "flawless"), + ) + async def test_gate_projection_replaces_manual_gate_change_exactly(self): role_by_id = { role_id: SimpleNamespace(id=role_id, managed=False, position=position) @@ -444,6 +688,12 @@ async def test_gate_projection_replaces_manual_gate_change_exactly(self): async def test_congratulations_ping_users_but_not_roles_or_reply_author(self): key = nhmisc.SourceMessageKey(120, 121, 122) target_role_id = nhmisc.GATE_TIER_ROLE_IDS[0] + custom_role_id = 900 + self._insert_definitions( + 120, + ("garden_of_grind", "Garden of Grind", custom_role_id), + ("flawless", "Flawless", None), + ) await self.store.claim( key, 123, @@ -455,6 +705,12 @@ async def test_congratulations_ping_users_but_not_roles_or_reply_author(self): grant_solo=True, ), ), + ( + nhmisc.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", custom_role_id + ), + nhmisc.GateIncrementAchievementPlan("flawless", "Flawless"), + ), ) await self.store.mark_member_completed(key, 0) snapshot = await self.store.finalize_operation(key) @@ -480,15 +736,121 @@ async def test_congratulations_ping_users_but_not_roles_or_reply_author(self): content, f"🎉 **Congratulations!**\n" f"<@124> <@&{target_role_id}> " - f"<@&{nhmisc.SINGLEPLAYER_GATE_COMPLETED_ROLE_ID}>", + f"<@&{nhmisc.SINGLEPLAYER_GATE_COMPLETED_ROLE_ID}> " + f"<@&{custom_role_id}> Flawless", ) self.assertTrue(allowed_mentions.users) self.assertFalse(allowed_mentions.roles) self.assertFalse(allowed_mentions.everyone) self.assertFalse(allowed_mentions.replied_user) + self.assertEqual( + [user.id for user in allowed_mentions.users], + [124], + ) + self.assertEqual( + source.reply.await_args.kwargs["nonce"], + f"gate-{snapshot.operation.operation_id}", + ) persisted = await self.store.get_operation(key) self.assertEqual(persisted.operation.result_message_id, 125) + async def test_recovery_updates_the_single_existing_congratulations_message(self): + key = nhmisc.SourceMessageKey(120, 121, 122) + target_role_id = nhmisc.GATE_TIER_ROLE_IDS[0] + self._insert_definitions(120, ("flawless", "Flawless", None)) + await self.store.claim( + key, + 123, + ( + nhmisc.GateIncrementMemberPlan(124, (), target_role_id), + nhmisc.GateIncrementMemberPlan(125, (), target_role_id), + ), + (nhmisc.GateIncrementAchievementPlan("flawless", "Flawless"),), + ) + await self.store.mark_member_completed(key, 0) + await self.store.mark_member_failed(key, 1, "discord_error") + partial = await self.store.finalize_operation(key) + result = SimpleNamespace( + id=126, + channel=SimpleNamespace(id=121), + edit=mock.AsyncMock(), + ) + channel = SimpleNamespace( + id=121, + get_partial_message=mock.Mock(return_value=result), + ) + source = SimpleNamespace( + id=122, + channel=channel, + reply=mock.AsyncMock(return_value=result), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._gate_increment_store = self.store + + with mock.patch.object( + nhmisc.discord, + "AllowedMentions", + side_effect=SimpleNamespace, + ): + self.assertTrue( + await cog._publish_gate_increment_result(source, partial) + ) + await self.store.mark_member_completed(key, 1) + completed = await self.store.finalize_operation(key) + self.assertTrue( + await cog._publish_gate_increment_result(source, completed) + ) + + source.reply.assert_awaited_once() + channel.get_partial_message.assert_called_once_with(result.id) + result.edit.assert_awaited_once() + updated_content = result.edit.await_args.kwargs["content"] + self.assertIn("<@124>", updated_content) + self.assertIn("<@125>", updated_content) + self.assertEqual(updated_content.count("🎉 **Congratulations!**"), 1) + + async def test_recovery_logs_only_newly_completed_members(self): + key = nhmisc.SourceMessageKey(120, 121, 122) + target_role_id = nhmisc.GATE_TIER_ROLE_IDS[0] + await self.store.claim( + key, + 123, + ( + nhmisc.GateIncrementMemberPlan(124, (), target_role_id), + nhmisc.GateIncrementMemberPlan(125, (), target_role_id), + ), + ) + await self.store.mark_member_completed(key, 0) + await self.store.mark_member_failed(key, 1, "discord_error") + partial = await self.store.finalize_operation(key) + source = SimpleNamespace( + id=122, + guild=SimpleNamespace(id=120), + channel=SimpleNamespace(id=121), + ) + cog = object.__new__(nhmisc.NHMisc) + cog._gate_increment_store = self.store + cog._send_moderation_log = mock.AsyncMock(return_value=True) + + self.assertTrue( + await cog._publish_gate_increment_moderation_log(source, 123, partial) + ) + await self.store.mark_member_completed(key, 1) + completed = await self.store.finalize_operation(key) + self.assertTrue( + await cog._publish_gate_increment_moderation_log( + source, 123, completed + ) + ) + + self.assertEqual(cog._send_moderation_log.await_count, 2) + first_log = cog._send_moderation_log.await_args_list[0].args[1] + second_log = cog._send_moderation_log.await_args_list[1].args[1] + self.assertIn("<@124>", first_log) + self.assertNotIn("<@125>", first_log) + self.assertNotIn("<@124>", second_log) + self.assertIn("<@125>", second_log) + class _CommandTree: def __init__(self): @@ -678,6 +1040,47 @@ async def test_confirm_error_keeps_review_embed_visible(self): rendered, ) + async def test_confirmation_definition_failure_uses_operational_error_handler(self): + interaction = self._interaction() + source = SimpleNamespace( + guild=SimpleNamespace(id=1), + channel=SimpleNamespace(id=2), + id=3, + ) + candidate = nhmisc.GateIncrementCandidate( + 10, "Player", (), None, nhmisc.GATE_TIER_ROLE_IDS[0] + ) + view = SimpleNamespace( + source_message=source, + selected_user_ids={10}, + custom_achievements=(), + selected_custom_achievement_keys=set(), + render_embed=mock.Mock(), + ) + error = RuntimeError("definitions unavailable") + cog = object.__new__(nhmisc.NHMisc) + cog._achievement_store = SimpleNamespace( + is_bootstrapped=mock.AsyncMock(return_value=True), + list_definitions=mock.AsyncMock(side_effect=error), + ) + cog._fetch_gate_increment_source = mock.AsyncMock(return_value=source) + cog._fetch_gate_increment_candidates = mock.AsyncMock( + return_value=(candidate,) + ) + cog._validate_gate_increment_candidate_count = mock.Mock() + cog._require_private_moderation_log_channel = mock.AsyncMock() + cog._handle_achievement_interaction_failure = mock.AsyncMock() + + with mock.patch.object(nhmisc, "_validate_gate_increment_configuration"): + await cog._confirm_gate_increment_review(interaction, view) + + cog._handle_achievement_interaction_failure.assert_awaited_once_with( + interaction, + "confirm gate increment review", + error, + public_defer=False, + ) + async def test_successful_confirm_emits_one_moderation_log(self): interaction = self._interaction() guild = SimpleNamespace(id=1) @@ -695,12 +1098,26 @@ async def test_successful_confirm_emits_one_moderation_log(self): target_ordinal=1, highest_ordinal=0, ) + definition = SimpleNamespace( + key="garden_of_grind", + display_name="Garden of Grind", + role_id=50, + grantable=True, + ) view = SimpleNamespace( source_message=source, selected_user_ids={10}, solo_gater_enabled=False, + custom_achievements=(definition,), + selected_custom_achievement_keys={"garden_of_grind"}, ) snapshot = SimpleNamespace( + operation=SimpleNamespace(key=nhmisc.SourceMessageKey(1, 2, 3)), + custom_achievements=( + nhmisc.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", 50 + ), + ), members=( StoredGateIncrementMember( position=0, @@ -709,12 +1126,17 @@ async def test_successful_confirm_emits_one_moderation_log(self): target_role_id=nhmisc.GATE_TIER_ROLE_IDS[0], state=nhmisc.MemberState.COMPLETED, failure_code=None, + custom_achievement_keys=("garden_of_grind",), ), ) ) cog = object.__new__(nhmisc.NHMisc) cog._achievement_store = SimpleNamespace( - is_bootstrapped=mock.AsyncMock(return_value=True) + is_bootstrapped=mock.AsyncMock(return_value=True), + list_definitions=mock.AsyncMock(return_value=(definition,)), + get_profile=mock.AsyncMock( + return_value=SimpleNamespace(boolean_keys=()) + ), ) cog._fetch_gate_increment_source = mock.AsyncMock(return_value=source) cog._fetch_gate_increment_candidates = mock.AsyncMock( @@ -723,7 +1145,8 @@ async def test_successful_confirm_emits_one_moderation_log(self): cog._validate_gate_increment_candidate_count = mock.Mock() cog._gate_increment_review_is_stale = mock.Mock(return_value=False) cog._gate_increment_store = SimpleNamespace( - claim=mock.AsyncMock(return_value=SimpleNamespace(created=True)) + claim=mock.AsyncMock(return_value=SimpleNamespace(created=True)), + mark_moderation_logged=mock.AsyncMock(), ) cog._execute_gate_increment_operation = mock.AsyncMock(return_value=snapshot) cog._publish_gate_increment_result = mock.AsyncMock(return_value=True) @@ -736,9 +1159,19 @@ async def test_successful_confirm_emits_one_moderation_log(self): await cog._confirm_gate_increment_review(interaction, view) cog._send_moderation_log.assert_awaited_once() + claimed_achievements = cog._gate_increment_store.claim.await_args.args[3] + self.assertEqual( + claimed_achievements, + ( + nhmisc.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", 50 + ), + ), + ) audit = cog._send_moderation_log.await_args.args[1] self.assertIn("Gate incremented", audit) self.assertIn("<@10> Gate 1", audit) + self.assertIn("<@&50>", audit) self.assertIn("https://discord.com/channels/1/2/3", audit) async def test_moderation_log_failure_does_not_block_congratulations(self): @@ -762,8 +1195,11 @@ async def test_moderation_log_failure_does_not_block_congratulations(self): source_message=source, selected_user_ids={10}, solo_gater_enabled=False, + custom_achievements=(), + selected_custom_achievement_keys=set(), ) snapshot = SimpleNamespace( + custom_achievements=(), members=( StoredGateIncrementMember( position=0, @@ -777,7 +1213,8 @@ async def test_moderation_log_failure_does_not_block_congratulations(self): ) cog = object.__new__(nhmisc.NHMisc) cog._achievement_store = SimpleNamespace( - is_bootstrapped=mock.AsyncMock(return_value=True) + is_bootstrapped=mock.AsyncMock(return_value=True), + list_definitions=mock.AsyncMock(return_value=()), ) cog._fetch_gate_increment_source = mock.AsyncMock(return_value=source) cog._fetch_gate_increment_candidates = mock.AsyncMock(return_value=(candidate,)) @@ -818,12 +1255,15 @@ async def test_missing_private_moderation_log_blocks_claim(self): source_message=source, selected_user_ids={10}, solo_gater_enabled=False, + custom_achievements=(), + selected_custom_achievement_keys=set(), render_embed=mock.Mock(return_value=object()), ) claim = mock.AsyncMock() cog = object.__new__(nhmisc.NHMisc) cog._achievement_store = SimpleNamespace( - is_bootstrapped=mock.AsyncMock(return_value=True) + is_bootstrapped=mock.AsyncMock(return_value=True), + list_definitions=mock.AsyncMock(return_value=()), ) cog._fetch_gate_increment_source = mock.AsyncMock(return_value=source) cog._fetch_gate_increment_candidates = mock.AsyncMock(return_value=(candidate,)) diff --git a/tests/test_gate_increment_store.py b/tests/test_gate_increment_store.py index 893215e..79f6a39 100644 --- a/tests/test_gate_increment_store.py +++ b/tests/test_gate_increment_store.py @@ -29,6 +29,39 @@ async def asyncSetUp(self): await self.first_store.initialize() await self.second_store.initialize() + def _insert_definitions(self, *definitions): + connection = sqlite3.connect(self.path) + try: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS achievement_definitions ( + guild_id INTEGER NOT NULL, + achievement_key TEXT NOT NULL, + display_name TEXT NOT NULL, + kind TEXT NOT NULL, + role_id INTEGER, + grantable INTEGER NOT NULL, + revocable INTEGER NOT NULL, + display_order INTEGER NOT NULL, + PRIMARY KEY (guild_id, achievement_key) + ) + """ + ) + connection.executemany( + """ + INSERT INTO achievement_definitions VALUES ( + 20, ?, ?, 'boolean', ?, 1, 1, ? + ) + """, + ( + (key, display_name, role_id, position) + for position, (key, display_name, role_id) in enumerate(definitions) + ), + ) + connection.commit() + finally: + connection.close() + async def test_concurrent_claims_consume_source_message_once(self): key = gate_increment_store.SourceMessageKey(1, 2, 3) plans = ( @@ -149,6 +182,188 @@ async def test_completed_member_activates_reserved_gate_and_solo_awards(self): ], ) + async def test_claim_persists_custom_definitions_and_only_new_member_awards(self): + self._insert_definitions( + ("garden_of_grind", "Garden of Grind", 30), + ("flawless", "Flawless", None), + ) + connection = sqlite3.connect(self.path) + try: + connection.executemany( + """ + INSERT INTO achievement_awards ( + guild_id, user_id, achievement_key, awarded_at, state + ) VALUES (20, 23, ?, 'earlier', 'active') + """, + (("garden_of_grind",), ("solo_gater",)), + ) + connection.commit() + finally: + connection.close() + key = gate_increment_store.SourceMessageKey(20, 21, 22) + plans = ( + gate_increment_store.GateIncrementMemberPlan( + 23, (), 24, grant_solo=True + ), + gate_increment_store.GateIncrementMemberPlan(25, (), 24), + ) + achievements = ( + gate_increment_store.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", 30 + ), + gate_increment_store.GateIncrementAchievementPlan( + "flawless", "Flawless" + ), + ) + + await self.first_store.claim(key, 26, plans, achievements) + reopened = gate_increment_store.GateIncrementStore(self.path) + await reopened.initialize() + + snapshot = await reopened.get_operation(key) + + self.assertIsNotNone(snapshot) + self.assertEqual(snapshot.custom_achievements, achievements) + self.assertFalse(snapshot.members[0].solo_awarded) + self.assertEqual(snapshot.members[0].custom_achievement_keys, ("flawless",)) + self.assertEqual( + snapshot.members[1].custom_achievement_keys, + ("garden_of_grind", "flawless"), + ) + + async def test_completed_member_activates_reserved_custom_awards(self): + self._insert_definitions( + ("garden_of_grind", "Garden of Grind", 30), + ("flawless", "Flawless", None), + ) + key = gate_increment_store.SourceMessageKey(20, 21, 22) + plan = gate_increment_store.GateIncrementMemberPlan(23, (), 24) + achievements = ( + gate_increment_store.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", 30 + ), + gate_increment_store.GateIncrementAchievementPlan( + "flawless", "Flawless" + ), + ) + await self.first_store.claim(key, 25, (plan,), achievements) + + await self.first_store.mark_member_completed(key, 0) + + connection = sqlite3.connect(self.path) + try: + rows = connection.execute( + """ + SELECT achievement_key, state + FROM achievement_awards + ORDER BY award_id + """ + ).fetchall() + finally: + connection.close() + self.assertEqual( + rows, + [ + ("stargate_completed", "active"), + ("garden_of_grind", "active"), + ("flawless", "active"), + ], + ) + + async def test_claim_rejects_a_changed_custom_definition_atomically(self): + self._insert_definitions(("garden_of_grind", "Garden of Grind", 31)) + key = gate_increment_store.SourceMessageKey(20, 21, 22) + + with self.assertRaises(gate_increment_store.AchievementDefinitionConflict): + await self.first_store.claim( + key, + 25, + (gate_increment_store.GateIncrementMemberPlan(23, (), 24),), + ( + gate_increment_store.GateIncrementAchievementPlan( + "garden_of_grind", "Garden of Grind", 30 + ), + ), + ) + + self.assertIsNone(await self.first_store.get_operation(key)) + + async def test_moderation_log_delivery_survives_store_reopen(self): + key = gate_increment_store.SourceMessageKey(20, 21, 22) + await self.first_store.claim( + key, + 25, + (gate_increment_store.GateIncrementMemberPlan(23, (), 24),), + ) + await self.first_store.mark_member_completed(key, 0) + + await self.first_store.mark_moderation_logged(key, (0,)) + reopened = gate_increment_store.GateIncrementStore(self.path) + await reopened.initialize() + snapshot = await reopened.get_operation(key) + + self.assertTrue(snapshot.members[0].moderation_logged) + + async def test_schema_upgrade_marks_existing_deliveries_as_settled(self): + legacy_path = Path(self.temp_dir.name) / "legacy-gate-increment.sqlite" + connection = sqlite3.connect(legacy_path) + try: + connection.executescript( + """ + CREATE TABLE gate_increment_operations ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL, + source_message_id INTEGER NOT NULL, + moderator_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + state TEXT NOT NULL, + selected_count INTEGER NOT NULL, + completed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + conflict_count INTEGER NOT NULL DEFAULT 0, + result_channel_id INTEGER, + result_message_id INTEGER, + lease_token TEXT, + publication_token TEXT, + UNIQUE (guild_id, channel_id, source_message_id) + ); + CREATE TABLE gate_increment_members ( + operation_id INTEGER NOT NULL, + position INTEGER NOT NULL, + user_id INTEGER, + expected_gate_role_ids TEXT NOT NULL, + target_role_id INTEGER, + state TEXT NOT NULL, + failure_code TEXT, + grant_solo INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (operation_id, position), + UNIQUE (operation_id, user_id) + ); + INSERT INTO gate_increment_operations VALUES ( + 1, 20, 21, 22, 25, 'now', 'now', 'completed', + 1, 1, 0, 0, 21, 30, NULL, NULL + ); + INSERT INTO gate_increment_members VALUES ( + 1, 0, 23, '[]', 24, 'completed', NULL, 0 + ); + """ + ) + connection.commit() + finally: + connection.close() + store = gate_increment_store.GateIncrementStore(legacy_path) + + await store.initialize() + snapshot = await store.get_operation( + gate_increment_store.SourceMessageKey(20, 21, 22) + ) + + self.assertEqual(snapshot.operation.published_completed_count, 1) + self.assertTrue(snapshot.members[0].moderation_logged) + self.assertEqual(await store.list_interrupted_operations(), ()) + async def test_claimed_targets_survive_store_reopen(self): key = gate_increment_store.SourceMessageKey(10, 20, 30) plans = ( @@ -315,6 +530,7 @@ async def test_result_message_persists_without_reopening_role_execution(self): "publisher", 8001, 8006, + 1, ) self.assertEqual(snapshot.operation.result_channel_id, 8001) @@ -370,6 +586,7 @@ async def test_redaction_preserves_source_lock_and_active_recovery_data(self): await self.first_store.claim(completed_key, 10004, (completed_plan,)) await self.first_store.mark_member_completed(completed_key, 0) await self.first_store.finalize_operation(completed_key) + await self.first_store.mark_moderation_logged(completed_key, (0,)) self.assertTrue( await self.first_store.acquire_publication_lease( completed_key, @@ -381,6 +598,7 @@ async def test_redaction_preserves_source_lock_and_active_recovery_data(self): "publisher", 10001, 10007, + 1, ) await self.first_store.claim(active_key, 10004, (completed_plan,)) @@ -407,6 +625,15 @@ async def test_interrupted_operations_are_listed_for_startup_recovery(self): await self.first_store.claim(completed_key, 11006, (plan,)) await self.first_store.mark_member_completed(completed_key, 0) await self.first_store.finalize_operation(completed_key) + await self.first_store.mark_moderation_logged(completed_key, (0,)) + self.assertTrue( + await self.first_store.acquire_publication_lease( + completed_key, "publisher" + ) + ) + await self.first_store.record_result_message( + completed_key, "publisher", 11001, 11007, 1 + ) interrupted = await self.first_store.list_interrupted_operations() diff --git a/tests/test_gatecount.py b/tests/test_gatecount.py index 855d788..83a3cc5 100644 --- a/tests/test_gatecount.py +++ b/tests/test_gatecount.py @@ -87,6 +87,7 @@ def _load_nhmisc(): discord.Color = _Color discord.Embed = _Embed discord.AllowedMentions = _AllowedMentions + discord.Object = SimpleNamespace discord.HTTPException = _HTTPException discord.NotFound = _NotFound discord.Forbidden = _Forbidden