From 1ef2295c079d5d406ee11aa0762160640739bcf1 Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Tue, 8 Sep 2026 21:49:43 +0200 Subject: [PATCH] snapshot_poker_phase5 --- handlers/poker.js | 61 ++ package.json | 4 - utils/cookieEconomy.js | 48 +- utils/crumbPoker.js | 36 + utils/pokerDiscord.js | 2 + utils/pokerInteractions.js | 30 + utils/pokerProfile.js | 1288 +++++++++++++++++++++++++++++++++++ utils/profileMaintenance.js | 3 +- utils/profileSystem.js | 8 +- 9 files changed, 1462 insertions(+), 18 deletions(-) create mode 100644 utils/pokerProfile.js diff --git a/handlers/poker.js b/handlers/poker.js index 28d8997..be56e31 100644 --- a/handlers/poker.js +++ b/handlers/poker.js @@ -6,6 +6,12 @@ const { refreshPokerTableMessage } = require("../utils/pokerDiscord"); +const { + announcePokerHandProgress, + createPokerProfileContext, + recordPokerHandProfiles +} = require("../utils/pokerProfile"); + const TURN_CHECK_INTERVAL = 5 * 1000; @@ -91,6 +97,39 @@ async function processPokerTurns( client, result ); + + if ( + result.game + ?.data + ?.hand + ?.state === + "PAYOUT" + ) { + try { + await recordPokerHandProfiles( + result.game + ); + + const context = + await createPokerProfileContext( + client, + result.game + ); + + if (context) { + await announcePokerHandProgress( + context, + result.game + ); + } + } + catch (error) { + console.error( + `[PokerProfile] Timeout profile processing failed for ${result.game.gameId}:`, + error + ); + } + } } } } @@ -169,6 +208,28 @@ async function recoverPoker( client, game ); + + if ( + game.data?.hand?.state === + "PAYOUT" + ) { + await recordPokerHandProfiles( + game + ); + + const context = + await createPokerProfileContext( + client, + game + ); + + if (context) { + await announcePokerHandProgress( + context, + game + ); + } + } } catch (error) { console.error( diff --git a/package.json b/package.json index b6fa36e..e5d76c5 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,6 @@ "version": "1.0.0", "description": "", "main": "index.js", - "scripts": { - "test": "node --test", - "test:poker": "node --test tests/pokerEngine.test.js tests/crumbPoker.test.js" - }, "repository": { "type": "git", "url": "https://git.desq-gaming.de/admin_rb/ShaiBot.git" diff --git a/utils/cookieEconomy.js b/utils/cookieEconomy.js index 0d45b5f..b8291f4 100644 --- a/utils/cookieEconomy.js +++ b/utils/cookieEconomy.js @@ -474,20 +474,41 @@ async function clearCookieOperation( userId, operationId ) { - const cookies = - getCookieCollection(); + if ( + typeof operationId !== + "string" + || + !/^[A-Za-z0-9:_-]{1,160}$/.test( + operationId + ) + ) { + throw new Error( + "INVALID_COOKIE_OPERATION_ID" + ); + } - await cookies.updateOne( - { - userId - }, - { - $pull: { - appliedCookieOperations: - operationId + const { + cookies + } = + getCollections(); + + const result = + await cookies.updateOne( + { + userId + }, + { + $pull: { + appliedCookieOperations: + operationId + } } - } - ); + ); + + /* + * Keep the Cookie document tidy once the final temporary + * idempotency marker has been cleared. + */ await cookies.updateOne( { @@ -503,6 +524,9 @@ async function clearCookieOperation( } } ); + + return result.modifiedCount === + 1; } diff --git a/utils/crumbPoker.js b/utils/crumbPoker.js index 9e7c634..b645048 100644 --- a/utils/crumbPoker.js +++ b/utils/crumbPoker.js @@ -8,6 +8,11 @@ const { PokerEngineError } = require("./pokerEngine"); +const { + recordPokerActionProfile, + recordPokerHandProfiles +} = require("./pokerProfile"); + /* * ============================================================ * CONFIG @@ -1965,6 +1970,37 @@ function createPokerService({ ); } + /* + * Profile/stat history is secondary to the authoritative + * Poker state. A profile failure must never invalidate a + * successfully committed table action or payout. + */ + + try { + await recordPokerActionProfile( + nextGame, + userId, + action + ); + + if ( + nextGame.data.hand + ?.state === + "PAYOUT" + ) { + await recordPokerHandProfiles( + nextGame + ); + } + } + + catch (error) { + console.error( + `[PokerProfile] Failed to persist profile history for ${nextGame.gameId}:`, + error + ); + } + return clone(nextGame); } diff --git a/utils/pokerDiscord.js b/utils/pokerDiscord.js index 7d15ac8..c8fd98b 100644 --- a/utils/pokerDiscord.js +++ b/utils/pokerDiscord.js @@ -127,6 +127,8 @@ function formatPokerError( return ( known[error?.code] ?? + known[error?.message] + ?? error?.message ?? "The Poker action could not be completed." diff --git a/utils/pokerInteractions.js b/utils/pokerInteractions.js index 7254bcf..802dee8 100644 --- a/utils/pokerInteractions.js +++ b/utils/pokerInteractions.js @@ -9,6 +9,10 @@ const { refreshPokerTableMessage } = require("./pokerDiscord"); +const { + announcePokerHandProgress +} = require("./pokerProfile"); + async function executePokerAction( interaction, @@ -50,6 +54,19 @@ async function executePokerAction( game ); + try { + await announcePokerHandProgress( + interaction, + game + ); + } + catch (error) { + console.error( + `[PokerProfile] Failed to announce completed hand ${game.gameId}:`, + error + ); + } + return interaction.editReply({ content: typeof successText === @@ -145,6 +162,19 @@ async function executeMinimumRaise( game ); + try { + await announcePokerHandProgress( + interaction, + game + ); + } + catch (error) { + console.error( + `[PokerProfile] Failed to announce completed hand ${game.gameId}:`, + error + ); + } + return interaction.editReply({ content: `✅ ${type === "bet" ? "Bet" : "Raised to"} ${legal.minRaiseTo.toLocaleString()} 🍪`, diff --git a/utils/pokerProfile.js b/utils/pokerProfile.js new file mode 100644 index 0000000..87145c7 --- /dev/null +++ b/utils/pokerProfile.js @@ -0,0 +1,1288 @@ +const { + EmbedBuilder +} = require("discord.js"); + +const { + getProfileCollections, + getDefaultStats, + trackProfileEvent, + trackGameActivity +} = require("./profileSystem"); + +const { + getUnixTime +} = require("./cookieEconomy"); + + +const POKER_GAME_KEY = + "poker"; + +const PROFILE_HAND_HISTORY_LIMIT = + 100; + +const PROFILE_ACTION_HISTORY_LIMIT = + 250; + + +const ACTION_STAT_PATHS = { + fold: + "games.poker.folds", + check: + "games.poker.checks", + call: + "games.poker.calls", + bet: + "games.poker.bets", + raise: + "games.poker.raises", + all_in: + "games.poker.allInActions" +}; + + +const HAND_RANK_STAT_PATHS = { + "High Card": + "games.poker.handRanks.highCard", + Pair: + "games.poker.handRanks.pair", + "Two Pair": + "games.poker.handRanks.twoPair", + "Three of a Kind": + "games.poker.handRanks.threeOfAKind", + Straight: + "games.poker.handRanks.straight", + Flush: + "games.poker.handRanks.flush", + "Full House": + "games.poker.handRanks.fullHouse", + "Four of a Kind": + "games.poker.handRanks.fourOfAKind", + "Straight Flush": + "games.poker.handRanks.straightFlush", + "Royal Flush": + "games.poker.handRanks.royalFlush" +}; + + +async function ensurePokerProfileDocument( + userId, + guildId +) { + const { + stats + } = + getProfileCollections(); + + await stats.updateOne( + { + userId, + guildId + }, + { + $setOnInsert: + getDefaultStats( + userId, + guildId + ) + }, + { + upsert: true + } + ); +} + + +function getPokerHandId( + game +) { + if ( + !game?.gameId + || + !Number.isSafeInteger( + Number( + game?.data?.handNumber + ) + ) + ) { + return null; + } + + return ( + `${game.gameId}:hand:${Number(game.data.handNumber)}` + ); +} + + +function getPokerActionId( + game +) { + if ( + !game?.gameId + || + !Number.isSafeInteger( + Number(game.version) + ) + ) { + return null; + } + + return ( + `${game.gameId}:action:v${Number(game.version)}` + ); +} + + +function normalizeActionType( + action +) { + return String( + action?.type ?? "" + ).toLowerCase(); +} + + +function normalizeCardRank( + card +) { + return String( + card?.rank ?? "" + ).toUpperCase(); +} + + +function isPocketAces( + holeCards +) { + return ( + Array.isArray(holeCards) + && + holeCards.length === 2 + && + holeCards.every( + card => + normalizeCardRank(card) === + "A" + ) + ); +} + + +function isSevenDeuce( + holeCards +) { + if ( + !Array.isArray(holeCards) + || + holeCards.length !== 2 + ) { + return false; + } + + const ranks = + holeCards + .map( + normalizeCardRank + ) + .sort(); + + return ( + ranks[0] === "2" + && + ranks[1] === "7" + ); +} + + +function getPotSizeFromResult( + result +) { + return Object.values( + result?.payouts ?? {} + ).reduce( + (sum, value) => + sum + + Number(value ?? 0), + 0 + ); +} + + +function getPlayerOutcome( + game, + handPlayer +) { + const hand = + game?.data?.hand; + + const result = + hand?.result ?? + game?.data?.lastHandResult; + + const winnerSeats = + Array.isArray( + result?.winnerSeats + ) + ? result.winnerSeats + : []; + + const isWinner = + winnerSeats.includes( + handPlayer.seat + ); + + const isSplitWinner = + isWinner + && + winnerSeats.length > 1; + + const finalStack = + Number( + handPlayer.stack ?? 0 + ); + + const startingStack = + Number( + handPlayer.startingStack ?? 0 + ); + + const profit = + finalStack - + startingStack; + + const potSize = + getPotSizeFromResult( + result + ); + + let resultName = + "loss"; + + if (isSplitWinner) { + resultName = + "push"; + } + + else if (isWinner) { + resultName = + "win"; + } + + const reachedShowdown = + result?.type === + "showdown" + && + handPlayer.folded !== + true; + + return { + result, + winnerSeats, + isWinner, + isSplitWinner, + resultName, + profit, + potSize, + reachedShowdown, + allIn: + handPlayer.allIn === + true, + handRank: + handPlayer.handRank ?? + null, + pocketAces: + isPocketAces( + handPlayer.holeCards + ), + sevenDeuce: + isSevenDeuce( + handPlayer.holeCards + ), + foldWin: + isWinner + && + result?.type === + "fold", + fullTableWin: + isWinner + && + Array.isArray(hand?.players) + && + hand.players.length === 6 + }; +} + + +async function recordPokerActionProfile( + game, + userId, + action +) { + const guildId = + game?.guildId; + + const actionType = + normalizeActionType( + action + ); + + const statPath = + ACTION_STAT_PATHS[ + actionType + ]; + + const actionId = + getPokerActionId( + game + ); + + if ( + !guildId + || + !userId + || + !statPath + || + !actionId + ) { + return false; + } + + await ensurePokerProfileDocument( + String(userId), + String(guildId) + ); + + const { + stats + } = + getProfileCollections(); + + const result = + await stats.updateOne( + { + userId: + String(userId), + guildId: + String(guildId), + "games.poker.processedActions": { + $ne: + actionId + } + }, + { + $inc: { + [statPath]: + 1 + }, + $push: { + "games.poker.processedActions": { + $each: [ + actionId + ], + $slice: + -PROFILE_ACTION_HISTORY_LIMIT + } + } + } + ); + + return ( + result.modifiedCount === + 1 + ); +} + + +async function repairProfileBestStreaks( + userId, + guildId +) { + const { + stats + } = + getProfileCollections(); + + const current = + await stats.findOne( + { + userId, + guildId + }, + { + projection: { + "games.poker.currentWinStreak": + 1, + "games.poker.currentLossStreak": + 1, + "streaks.currentWin": + 1, + "streaks.currentLoss": + 1 + } + } + ); + + await stats.updateOne( + { + userId, + guildId + }, + { + $max: { + "games.poker.bestWinStreak": + Number( + current + ?.games + ?.poker + ?.currentWinStreak ?? + 0 + ), + "games.poker.bestLossStreak": + Number( + current + ?.games + ?.poker + ?.currentLossStreak ?? + 0 + ), + "streaks.bestWin": + Number( + current + ?.streaks + ?.currentWin ?? + 0 + ), + "streaks.bestLoss": + Number( + current + ?.streaks + ?.currentLoss ?? + 0 + ) + } + } + ); +} + + +async function recordPokerHandForPlayer( + game, + handPlayer +) { + const guildId = + String( + game.guildId + ); + + const userId = + String( + handPlayer.userId + ); + + const handId = + getPokerHandId( + game + ); + + if (!handId) { + return { + processed: false, + userId, + reason: + "INVALID_HAND_ID" + }; + } + + await ensurePokerProfileDocument( + userId, + guildId + ); + + const outcome = + getPlayerOutcome( + game, + handPlayer + ); + + const inc = { + "games.total.played": + 1, + "games.poker.played": + 1, + "games.poker.handsPlayed": + 1 + }; + + if ( + outcome.resultName === + "win" + ) { + inc[ + "games.total.won" + ] = 1; + + inc[ + "games.poker.won" + ] = 1; + + inc[ + "games.poker.handsWon" + ] = 1; + } + + else if ( + outcome.resultName === + "loss" + ) { + inc[ + "games.total.lost" + ] = 1; + + inc[ + "games.poker.lost" + ] = 1; + + inc[ + "games.poker.handsLost" + ] = 1; + } + + else { + inc[ + "games.total.pushed" + ] = 1; + + inc[ + "games.poker.pushed" + ] = 1; + + inc[ + "games.poker.handsPushed" + ] = 1; + } + + const set = {}; + + if ( + outcome.resultName === + "win" + ) { + inc[ + "games.poker.currentWinStreak" + ] = 1; + + inc[ + "streaks.currentWin" + ] = 1; + + set[ + "games.poker.currentLossStreak" + ] = 0; + + set[ + "streaks.currentLoss" + ] = 0; + } + + else if ( + outcome.resultName === + "loss" + ) { + inc[ + "games.poker.currentLossStreak" + ] = 1; + + inc[ + "streaks.currentLoss" + ] = 1; + + set[ + "games.poker.currentWinStreak" + ] = 0; + + set[ + "streaks.currentWin" + ] = 0; + } + + if ( + outcome.profit > 0 + ) { + inc[ + "games.poker.cookiesWon" + ] = + outcome.profit; + + inc[ + "economy.cookiesObtainedTotal" + ] = + outcome.profit; + } + + else if ( + outcome.profit < 0 + ) { + const loss = + Math.abs( + outcome.profit + ); + + inc[ + "games.poker.cookiesLost" + ] = + loss; + + inc[ + "economy.cookiesLostTotal" + ] = + loss; + } + + if ( + outcome.reachedShowdown + ) { + inc[ + "games.poker.showdowns" + ] = 1; + + if (outcome.isWinner) { + inc[ + "games.poker.showdownWins" + ] = 1; + } + } + + if ( + outcome.allIn + ) { + inc[ + "games.poker.allIns" + ] = 1; + + if (outcome.isWinner) { + inc[ + "games.poker.allInWins" + ] = 1; + } + } + + if ( + outcome.foldWin + ) { + inc[ + "games.poker.foldWins" + ] = 1; + } + + if ( + outcome.fullTableWin + ) { + inc[ + "games.poker.fullTableWins" + ] = 1; + } + + if ( + outcome.isWinner + && + outcome.pocketAces + ) { + inc[ + "games.poker.pocketAcesWins" + ] = 1; + } + + if ( + outcome.isWinner + && + outcome.sevenDeuce + ) { + inc[ + "games.poker.sevenDeuceWins" + ] = 1; + } + + const handRankPath = + HAND_RANK_STAT_PATHS[ + outcome.handRank?.name + ]; + + if ( + outcome.reachedShowdown + && + handRankPath + ) { + inc[ + handRankPath + ] = 1; + } + + const { + stats + } = + getProfileCollections(); + + const update = { + $inc: + inc, + ...(Object.keys(set).length + ? { + $set: + set + } + : {}), + $push: { + "games.poker.processedHands": { + $each: [ + handId + ], + $slice: + -PROFILE_HAND_HISTORY_LIMIT + } + } + }; + + if (outcome.isWinner) { + update.$max = { + "games.poker.largestPotWon": + outcome.potSize + }; + } + + const write = + await stats.updateOne( + { + userId, + guildId, + "games.poker.processedHands": { + $ne: + handId + } + }, + update + ); + + if ( + write.modifiedCount !== + 1 + ) { + await repairProfileBestStreaks( + userId, + guildId + ); + + return { + processed: false, + userId, + handId, + outcome + }; + } + + await repairProfileBestStreaks( + userId, + guildId + ); + + try { + await trackGameActivity( + userId, + guildId + ); + } + + catch (error) { + console.error( + `[PokerProfile] Activity tracking failed for ${userId}:`, + error + ); + } + + return { + processed: true, + userId, + handId, + outcome + }; +} + + +async function recordPokerHandProfiles( + game +) { + const hand = + game?.data?.hand; + + if ( + !hand + || + hand.state !== + "PAYOUT" + || + !hand.result + || + !Array.isArray( + hand.players + ) + ) { + return []; + } + + const results = []; + + for ( + const handPlayer + of hand.players + ) { + try { + results.push( + await recordPokerHandForPlayer( + game, + handPlayer + ) + ); + } + + catch (error) { + console.error( + `[PokerProfile] Failed to record hand ${getPokerHandId(game) ?? "unknown"} for ${handPlayer.userId}:`, + error + ); + + results.push({ + processed: false, + userId: + String( + handPlayer.userId + ), + error + }); + } + } + + return results; +} + + +async function claimPokerBadge( + context, + userId, + badgeId, + metadata = null +) { + const guildId = + context?.guildId; + + if (!guildId) { + return false; + } + + const { + badges + } = + getProfileCollections(); + + const set = { + ownerId: + String(userId), + unlockedAt: + getUnixTime() + }; + + if ( + metadata + && + typeof metadata === + "object" + ) { + set.metadata = + metadata; + } + + const badge = + await badges.findOneAndUpdate( + { + guildId: + String(guildId), + badgeId, + ownerId: + null + }, + { + $set: + set + }, + { + returnDocument: + "after" + } + ); + + if ( + !badge + || + badge.ownerId !== + String(userId) + ) { + return false; + } + + if ( + !context.channel + ) { + return true; + } + + try { + await context.channel.send({ + embeds: [ + new EmbedBuilder() + .setTitle( + "🏅 Unique Badge Claimed!" + ) + .setDescription( + [ + `${badge.emoji ?? "♠️"} **${badge.name ?? badge.badgeId}**`, + "", + `<@${userId}> claimed a guild-unique Poker badge!`, + "", + badge.description ?? + "A unique Poker badge has been claimed.", + "", + "*This badge can no longer be earned by another player.*" + ].join("\n") + ) + .setFooter({ + text: + `badge:${badge.badgeId}` + }) + .setTimestamp() + ] + }); + } + + catch (error) { + console.error( + `[PokerBadge] Failed to announce ${badgeId} for ${userId}:`, + error + ); + } + + return true; +} + + +async function evaluatePokerBadges( + context, + userId, + outcome +) { + const { + stats + } = + getProfileCollections(); + + const document = + await stats.findOne({ + userId: + String(userId), + guildId: + String(context.guildId) + }); + + const poker = + document?.games?.poker ?? {}; + + if ( + Number( + poker.won ?? 0 + ) >= 1 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_win" + ); + } + + if ( + Number( + poker.allInWins ?? 0 + ) >= 1 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_allin_win" + ); + } + + if ( + Number( + poker.pocketAcesWins ?? 0 + ) >= 1 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_pocket_aces_win" + ); + } + + if ( + Number( + poker.handRanks + ?.royalFlush ?? 0 + ) >= 1 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_royal_flush" + ); + } + + if ( + Number( + poker.fullTableWins ?? 0 + ) >= 1 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_full_table_win" + ); + } + + if ( + Number( + poker.largestPotWon ?? 0 + ) >= 5000 + ) { + await claimPokerBadge( + context, + userId, + "first_poker_5000_pot", + { + pot: + outcome?.potSize ?? + poker.largestPotWon + } + ); + } +} + + +async function claimPokerAnnouncement( + game, + userId +) { + const handId = + getPokerHandId( + game + ); + + if (!handId) { + return false; + } + + const { + stats + } = + getProfileCollections(); + + const result = + await stats.updateOne( + { + userId: + String(userId), + guildId: + String(game.guildId), + "games.poker.announcedHands": { + $ne: + handId + } + }, + { + $push: { + "games.poker.announcedHands": { + $each: [ + handId + ], + $slice: + -PROFILE_HAND_HISTORY_LIMIT + } + } + } + ); + + return ( + result.modifiedCount === + 1 + ); +} + + +async function announcePokerHandProgress( + context, + game +) { + if ( + !context?.guildId + || + !game?.data?.hand + || + game.data.hand.state !== + "PAYOUT" + || + !Array.isArray( + game.data.hand.players + ) + ) { + return []; + } + + const results = []; + + for ( + const handPlayer + of game.data.hand.players + ) { + const userId = + String( + handPlayer.userId + ); + + try { + const claimed = + await claimPokerAnnouncement( + game, + userId + ); + + if (!claimed) { + continue; + } + + const outcome = + getPlayerOutcome( + game, + handPlayer + ); + + await trackProfileEvent( + context, + userId, + { + type: + "profile_refresh", + source: + "poker", + game: + POKER_GAME_KEY, + result: + outcome.resultName, + profit: + outcome.profit, + allIn: + outcome.allIn, + handRank: + outcome.handRank?.name ?? + null, + pocketAces: + outcome.pocketAces, + sevenDeuce: + outcome.sevenDeuce, + foldWin: + outcome.foldWin, + fullTableWin: + outcome.fullTableWin, + pot: + outcome.potSize + } + ); + + await evaluatePokerBadges( + context, + userId, + outcome + ); + + results.push({ + userId, + announced: true + }); + } + + catch (error) { + console.error( + `[PokerProfile] Failed to refresh achievements/badges for ${userId}:`, + error + ); + + results.push({ + userId, + announced: false, + error + }); + } + } + + return results; +} + + +async function createPokerProfileContext( + client, + game +) { + if ( + !client + || + !game?.guildId + ) { + return null; + } + + let channel = + null; + + if ( + game.channelId + ) { + try { + channel = + await client.channels.fetch( + game.channelId + ); + } + + catch {} + } + + return { + guildId: + String(game.guildId), + channel, + replied: + false, + deferred: + false + }; +} + + +module.exports = { + ensurePokerProfileDocument, + getPokerHandId, + getPokerActionId, + getPlayerOutcome, + + recordPokerActionProfile, + recordPokerHandProfiles, + announcePokerHandProgress, + createPokerProfileContext, + + claimPokerBadge, + evaluatePokerBadges +}; diff --git a/utils/profileMaintenance.js b/utils/profileMaintenance.js index 69f33f2..514501f 100644 --- a/utils/profileMaintenance.js +++ b/utils/profileMaintenance.js @@ -754,7 +754,8 @@ async function auditProfileData( "slots", "blackjack", "roulette", - "heist" + "heist", + "poker" ] ) { const value = diff --git a/utils/profileSystem.js b/utils/profileSystem.js index 5327426..6dc0e22 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -935,6 +935,9 @@ async function trackProfileEvent( ); break; + case "profile_refresh": + break; + default: return; } @@ -2041,7 +2044,8 @@ async function buildStatsEmbed( ["🎰 Slots", stats.games?.slots], ["🃏 Blackjack", stats.games?.blackjack], ["🎡 Roulette", stats.games?.roulette], - ["🏦 Heist", stats.games?.heist] + ["🏦 Heist", stats.games?.heist], + ["♠️ Poker", stats.games?.poker] ].map(([name, game = {}]) => { const played = Number(game.played ?? 0); @@ -2923,6 +2927,8 @@ function formatCategoryName( "🎡 Roulette", heist: "🏦 Heist", + poker: + "♠️ Poker", collector: "🏆 Collector", other: