diff --git a/.gitignore b/.gitignore index c96e947..4b818c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .env -backup/ \ No newline at end of file +backup/ +*.bak \ No newline at end of file diff --git a/utils/bankruptcy.js b/utils/bankruptcy.js index 4481f01..dd7c37d 100644 --- a/utils/bankruptcy.js +++ b/utils/bankruptcy.js @@ -161,16 +161,26 @@ async function declareBankruptcy(interaction, userId = interaction.user.id) { }; } -await trackProfileEvent( - interaction, - userId, - { - type: "cookie_gain", - source: "bankruptcy", - amount: - BANKRUPTCY_GRANT - } -); + await trackProfileEvent( + interaction, + userId, + { + type: "cookie_gain", + source: "bankruptcy", + amount: + BANKRUPTCY_GRANT + } + ); + + await trackProfileEvent( + interaction, + userId, + { + type: "bankruptcy", + source: "bankruptcy", + amount: BANKRUPTCY_GRANT + } + ); return { ok: true, diff --git a/utils/crumbBlackjack.js b/utils/crumbBlackjack.js index 8097322..8f92e62 100644 --- a/utils/crumbBlackjack.js +++ b/utils/crumbBlackjack.js @@ -303,7 +303,6 @@ async function startBlackjack( type: "game_start", game: "blackjack", source, - bet, wager: bet, allIn, previousAllIn, diff --git a/utils/crumbRoulette.js b/utils/crumbRoulette.js index 469c6ee..c593aa8 100644 --- a/utils/crumbRoulette.js +++ b/utils/crumbRoulette.js @@ -502,7 +502,6 @@ await trackProfileEvent( type: "game_start", game: "roulette", source, - bet, wager: bet, allIn, previousAllIn, diff --git a/utils/crumbSlots.js b/utils/crumbSlots.js index 228503e..3527816 100644 --- a/utils/crumbSlots.js +++ b/utils/crumbSlots.js @@ -106,7 +106,9 @@ async function showSlots(interaction) { async function playSlots( interaction, bet, - source = "normal" + source = "normal", + previousAllIn = false, + previousBet = 0 ) { const ownerId = getSlotsSessionOwner( @@ -206,7 +208,6 @@ await trackProfileEvent( type: "game_start", game: "slots", source, - bet, wager: bet, allIn: bet === playerBalance, diff --git a/utils/profileMaintenance.js b/utils/profileMaintenance.js index 2275434..69f33f2 100644 --- a/utils/profileMaintenance.js +++ b/utils/profileMaintenance.js @@ -6,10 +6,18 @@ const { } = require("./profileSystem"); const { - getUnixTime + getUnixTime, + changeCookies } = require("./cookieEconomy"); +const DEFAULT_MAINTENANCE_REWARD_ID = + "maintenance_2026_08_27"; + +const DEFAULT_MAINTENANCE_REWARD_AMOUNT = + 5000; + + function getActiveDefinitions( achievementConfig ) { @@ -356,6 +364,292 @@ async function reconcileAllAchievements({ } + +async function grantMaintenanceReward({ + guildId, + rewardId = + DEFAULT_MAINTENANCE_REWARD_ID, + amount = + DEFAULT_MAINTENANCE_REWARD_AMOUNT, + dryRun = false +}) { + if (!guildId) { + throw new Error( + "guildId is required." + ); + } + + const normalizedRewardId = + String( + rewardId ?? "" + ).trim(); + + if (!normalizedRewardId) { + throw new Error( + "rewardId is required." + ); + } + + const normalizedAmount = + Number( + amount + ); + + if ( + !Number.isSafeInteger( + normalizedAmount + ) + || + normalizedAmount <= 0 + ) { + throw new Error( + "Maintenance reward amount must be a positive safe integer." + ); + } + + const { + stats + } = + getProfileCollections(); + + const userIds = + await stats.distinct( + "userId", + { + guildId + } + ); + + const summary = { + guildId, + rewardId: + normalizedRewardId, + amount: + normalizedAmount, + users: + userIds.length, + eligible: + 0, + granted: + 0, + alreadyClaimed: + 0, + failed: + 0, + dryRun, + results: [] + }; + + for (const userId of userIds) { + const existing = + await stats.findOne( + { + userId, + guildId + }, + { + projection: { + "maintenance.rewardsClaimed": + 1 + } + } + ); + + const rewardsClaimed = + Array.isArray( + existing + ?.maintenance + ?.rewardsClaimed + ) + ? existing + .maintenance + .rewardsClaimed + : []; + + if ( + rewardsClaimed.includes( + normalizedRewardId + ) + ) { + summary.alreadyClaimed++; + + summary.results.push({ + userId, + status: + "already_claimed" + }); + + continue; + } + + summary.eligible++; + + if (dryRun) { + summary.results.push({ + userId, + status: + "would_grant" + }); + + console.log( + `[ProfileMaintenance] Dry run: would grant ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}.` + ); + + continue; + } + + const reservation = + await stats.updateOne( + { + userId, + guildId, + "maintenance.rewardsClaimed": { + $ne: + normalizedRewardId + } + }, + { + $addToSet: { + "maintenance.rewardsClaimed": + normalizedRewardId + } + } + ); + + if ( + reservation.modifiedCount !== + 1 + ) { + summary.alreadyClaimed++; + + summary.results.push({ + userId, + status: + "already_claimed" + }); + + continue; + } + + try { + await changeCookies( + userId, + normalizedAmount + ); + + summary.granted++; + + summary.results.push({ + userId, + status: + "granted" + }); + + console.log( + `[ProfileMaintenance] Granted ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}.` + ); + } + + catch (error) { + await stats.updateOne( + { + userId, + guildId + }, + { + $pull: { + "maintenance.rewardsClaimed": + normalizedRewardId + } + } + ); + + summary.failed++; + + summary.results.push({ + userId, + status: + "failed", + error: + error?.message ?? + String(error) + }); + + console.error( + `[ProfileMaintenance] Failed to grant ${normalizedAmount} Cookies to ${userId} for ${normalizedRewardId}:`, + error + ); + } + } + + console.log( + `[ProfileMaintenance] Maintenance reward ${normalizedRewardId}: ${summary.granted} granted, ${summary.alreadyClaimed} already claimed, ${summary.failed} failed, ${summary.users} stored profile user(s) checked.` + ); + + return summary; +} + + +async function getMaintenanceRewardStatus({ + guildId, + rewardId = + DEFAULT_MAINTENANCE_REWARD_ID +}) { + if (!guildId) { + throw new Error( + "guildId is required." + ); + } + + const normalizedRewardId = + String( + rewardId ?? "" + ).trim(); + + if (!normalizedRewardId) { + throw new Error( + "rewardId is required." + ); + } + + const { + stats + } = + getProfileCollections(); + + const [ + users, + claimed + ] = + await Promise.all([ + stats.countDocuments({ + guildId + }), + + stats.countDocuments({ + guildId, + "maintenance.rewardsClaimed": + normalizedRewardId + }) + ]); + + return { + guildId, + rewardId: + normalizedRewardId, + amount: + DEFAULT_MAINTENANCE_REWARD_AMOUNT, + users, + claimed, + remaining: + Math.max( + 0, + users - claimed + ) + }; +} + + async function auditProfileData( guildId ) { @@ -783,6 +1077,65 @@ async function auditProfileData( } + /* + * ---------------------------------------------------- + * MAINTENANCE REWARDS + * ---------------------------------------------------- + */ + + const rewardsClaimed = + document + .maintenance + ?.rewardsClaimed; + + if ( + rewardsClaimed !== + undefined + && + !Array.isArray( + rewardsClaimed + ) + ) { + issues.push( + `${document.userId}: maintenance.rewardsClaimed is not an array.` + ); + } + + else if ( + Array.isArray( + rewardsClaimed + ) + ) { + const uniqueRewards = + new Set( + rewardsClaimed + ); + + if ( + uniqueRewards.size !== + rewardsClaimed.length + ) { + issues.push( + `${document.userId}: maintenance.rewardsClaimed contains duplicate reward IDs.` + ); + } + + if ( + rewardsClaimed.some( + rewardId => + typeof rewardId !== + "string" + || + !rewardId.trim() + ) + ) { + issues.push( + `${document.userId}: maintenance.rewardsClaimed contains an invalid reward ID.` + ); + } + } + + /* * ---------------------------------------------------- * SCHEMA VERSION @@ -1407,5 +1760,10 @@ function isValidWeekKey( module.exports = { reconcileUserAchievements, reconcileAllAchievements, - auditProfileData -}; \ No newline at end of file + grantMaintenanceReward, + getMaintenanceRewardStatus, + auditProfileData, + + DEFAULT_MAINTENANCE_REWARD_ID, + DEFAULT_MAINTENANCE_REWARD_AMOUNT +}; diff --git a/utils/profileSystem.js b/utils/profileSystem.js index 1209aca..b0691d9 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -409,6 +409,145 @@ async function ensureProfileSystem() { ); } +async function evaluateSocialRelationshipFlags( + userId, + guildId, + event +) { + const { + stats + } = + getProfileCollections(); + + + const document = + await stats.findOne({ + userId, + guildId + }); + + + if (!document) { + return; + } + + + const social = + document.social ?? {}; + + + const set = {}; + + + /* + * ======================================================== + * ET TU, CRUMB? + * ======================================================== + * + * Successfully steal from somebody who has + * previously gifted Cookies to you. + */ + + if ( + event.type === + "steal_success" + && + event.targetUserId + && + Array.isArray( + social.receivedFrom + ) + && + social.receivedFrom.includes( + event.targetUserId + ) + ) { + set[ + "special.etTuCrumb" + ] = 1; + } + + + /* + * ======================================================== + * NO HARD FEELINGS + * ======================================================== + * + * Give Cookies to somebody who has previously + * successfully stolen from you. + */ + + if ( + event.type === + "cookies_given" + && + event.targetUserId + && + Array.isArray( + social.successfullyStolenBy + ) + && + social.successfullyStolenBy.includes( + event.targetUserId + ) + ) { + set[ + "special.noHardFeelings" + ] = 1; + } + + + /* + * ======================================================== + * COOKIE LAUNDERING + * ======================================================== + * + * Give Cookies to somebody you have previously + * successfully stolen from. + */ + + if ( + event.type === + "cookies_given" + && + event.targetUserId + && + Array.isArray( + social.successfullyStolenFrom + ) + && + social.successfullyStolenFrom.includes( + event.targetUserId + ) + ) { + set[ + "special.cookieLaundering" + ] = 1; + } + + + if ( + Object.keys( + set + ).length === + 0 + ) { + return; + } + + + await stats.updateOne( + { + userId, + guildId + }, + { + $set: + set + } + ); +} + function getDefaultStats(userId, guildId) { return { userId, @@ -486,8 +625,21 @@ function getDefaultStats(userId, guildId) { bestLoss: 0 }, + social: { + giftedTo: [], + receivedFrom: [], + successfullyStolenFrom: [], + successfullyStolenBy: [] + }, + special: { - allInRunItBack: 0 + allInRunItBack: 0, + bankruptcyCount: 0, + bankruptcyRecoveryBest: 0, + + etTuCrumb: 0, + noHardFeelings: 0, + cookieLaundering: 0 }, activity: { @@ -517,6 +669,22 @@ function getDefaultStats(userId, guildId) { evening: 0 } }, + daily: { + dateKey: null, + + gamesPlayed: [], + gamesWon: [], + gamesLost: [], + + collectedCookies: false, + gambledAfterCollect: false, + + wonAnyGame: false, + lostAnyGame: false, + + gaveCookies: false, + stoleCookies: false + }, profileSchemaVersion: 2 }; @@ -590,16 +758,54 @@ async function trackProfileEvent( switch (event.type) { case "cookie_gain": await stats.updateOne( - { userId, guildId }, + { + userId, + guildId + }, { $inc: { "economy.cookiesObtainedTotal": event.amount + }, + + ...( + event.source === "give" && + event.senderUserId + ? { + $addToSet: { + "social.receivedFrom": + event.senderUserId + }, + + $set: { + "special.lastGiftSenderId": + event.senderUserId + } + } + : {} + ) + } + ); + break; + case "bankruptcy": + await stats.updateOne( + { + userId, + guildId + }, + { + $inc: { + "special.bankruptcyCount": + 1 + }, + + $set: { + "special.bankruptcyRecoveryBest": + 0 } } ); break; - case "cookie_loss": await stats.updateOne( { userId, guildId }, @@ -614,48 +820,87 @@ async function trackProfileEvent( case "cookies_given": await stats.updateOne( - { userId, guildId }, + { + userId, + guildId + }, { $inc: { "economy.cookiesGivenTotal": event.amount - } + }, + + ...(event.targetUserId + ? { + $addToSet: { + "social.giftedTo": + event.targetUserId + }, + + $set: { + "special.lastGiftTargetId": + event.targetUserId + } + } + : {}) } ); break; case "steal_success": await stats.updateOne( - { userId, guildId }, + { + userId, + guildId + }, { $inc: { "economy.stolenTotal": event.amount }, + $addToSet: { "unique.playersStolenFrom": + event.targetUserId, + + "social.successfullyStolenFrom": + event.targetUserId + }, + + $set: { + "special.lastSuccessfulStealTargetId": event.targetUserId } } ); break; - case "stolen_from_me": await stats.updateOne( - { userId, guildId }, + { + userId, + guildId + }, { $inc: { "economy.stolenFromMeTotal": event.amount }, + $addToSet: { "unique.playersStolenBy": + event.thiefUserId, + + "social.successfullyStolenBy": + event.thiefUserId + }, + + $set: { + "special.lastSuccessfulThiefId": event.thiefUserId } } ); break; - case "vault_steal": await stats.updateOne( { userId, guildId }, @@ -694,6 +939,53 @@ async function trackProfileEvent( return; } + + if ( + [ + "steal_success", + "cookies_given" + ].includes( + event.type + ) + ) { + await evaluateSocialRelationshipFlags( + userId, + guildId, + event + ); + } + + + if ( + [ + "cookie_gain", + "cookie_loss", + "game_result", + "bankruptcy" + ].includes( + event.type + ) + ) { + await updateBankruptcyRecovery( + userId, + guildId + ); + } + if ( + event.type === + "game_result" + ) { + await trackDailyGameState( + userId, + guildId, + event + ); + } + await trackDailyNarrativeState( + userId, + guildId, + event + ); const updatedStats = await getProfileStats( userId, @@ -804,18 +1096,22 @@ async function trackGameStart( const set = {}; - const previousBet = Number( - event.previousBet ?? 0 + const previousWager = Number( + event.previousWager ?? + event.previousBet ?? + 0 ); - const currentBet = Number( - event.bet ?? 0 + const currentWager = Number( + event.wager ?? + event.bet ?? + 0 ); const chainedAllIn = event.previousAllIn === true && - previousBet > 0 && - currentBet === previousBet; + previousWager > 0 && + currentWager === previousWager; if (chainedAllIn) { set["special.allInRunItBack"] = 1; @@ -1401,8 +1697,21 @@ async function evaluateBadges( event.type === "game_start" && event.source === "run_it_back" && event.previousAllIn === true && - Number(event.previousBet) > 0 && - Number(event.bet) === Number(event.previousBet) + Number( + event.previousWager ?? + event.previousBet ?? + 0 + ) > 0 && + Number( + event.wager ?? + event.bet ?? + 0 + ) === + Number( + event.previousWager ?? + event.previousBet ?? + 0 + ) ) { await tryClaimBadge( interaction, @@ -1434,15 +1743,14 @@ async function evaluateCollectorBadges( .find({ active: { $ne: false - }, - measure: { - $ne: - "achievementPercentage" } }) .toArray() - ).filter(achievement => - Boolean(achievement.stat) + ).filter( + achievement => + isStatBackfillableAchievement( + achievement + ) ); if ( @@ -2070,8 +2378,9 @@ async function buildAchievementsEmbed( const collectibleDefinitions = definitions.filter( definition => - definition.measure !== - "achievementPercentage" + isStatBackfillableAchievement( + definition + ) ); const unlockedCollectible = @@ -3279,6 +3588,371 @@ async function evaluateEventAchievements( ); } } +async function updateBankruptcyRecovery( + userId, + guildId +) { + const { + stats + } = + getProfileCollections(); + + const document = + await stats.findOne( + { + userId, + guildId + }, + { + projection: { + "special.bankruptcyCount": + 1 + } + } + ); + + + if ( + Number( + document + ?.special + ?.bankruptcyCount ?? 0 + ) <= 0 + ) { + return; + } + + + const balance = + await getCookieBalance( + userId + ); + + + await stats.updateOne( + { + userId, + guildId + }, + { + $max: { + "special.bankruptcyRecoveryBest": + balance + } + } + ); +} +async function trackDailyGameState( + userId, + guildId, + event +) { + if ( + event.type !== + "game_result" + ) { + return; + } + + const game = + String( + event.game ?? "" + ).toLowerCase(); + + const result = + String( + event.result ?? "" + ).toLowerCase(); + + if ( + ![ + "slots", + "blackjack", + "roulette", + "heist" + ].includes( + game + ) + ) { + return; + } + + const { + stats + } = + getProfileCollections(); + + const { + dateKey + } = + getActivityDateContext(); + + const document = + await stats.findOne( + { + userId, + guildId + }, + { + projection: { + daily: 1 + } + } + ); + + const currentDateKey = + document + ?.daily + ?.dateKey ?? null; + + if ( + currentDateKey !== + dateKey + ) { + await stats.updateOne( + { + userId, + guildId + }, + { + $set: { + "daily.dateKey": + dateKey, + + "daily.gamesPlayed": + [game], + + "daily.gamesWon": + result === "win" + ? [game] + : [], + + "daily.gamesLost": + result === "loss" + ? [game] + : [], + + "daily.collectedCookies": + false, + + "daily.gambledAfterCollect": + false, + + "daily.wonAnyGame": + result === "win", + + "daily.lostAnyGame": + result === "loss", + + "daily.gaveCookies": + false, + + "daily.stoleCookies": + false + } + } + ); + + return; + } + + const addToSet = { + "daily.gamesPlayed": + game + }; + + const set = {}; + + if ( + result === + "win" + ) { + addToSet[ + "daily.gamesWon" + ] = game; + + set[ + "daily.wonAnyGame" + ] = true; + } + + if ( + result === + "loss" + ) { + addToSet[ + "daily.gamesLost" + ] = game; + + set[ + "daily.lostAnyGame" + ] = true; + } + + await stats.updateOne( + { + userId, + guildId + }, + { + $addToSet: + addToSet, + + ...( + Object.keys( + set + ).length + ? { + $set: + set + } + : {} + ) + } + ); +} +async function trackDailyNarrativeState( + userId, + guildId, + event +) { + const { + stats + } = + getProfileCollections(); + + const { + dateKey + } = + getActivityDateContext(); + + const document = + await stats.findOne( + { + userId, + guildId + }, + { + projection: { + daily: 1 + } + } + ); + + const currentDateKey = + document + ?.daily + ?.dateKey ?? null; + + if ( + currentDateKey !== + dateKey + ) { + await stats.updateOne( + { + userId, + guildId + }, + { + $set: { + "daily.dateKey": + dateKey, + + "daily.gamesPlayed": + [], + + "daily.gamesWon": + [], + + "daily.gamesLost": + [], + + "daily.collectedCookies": + false, + + "daily.gambledAfterCollect": + false, + + "daily.wonAnyGame": + false, + + "daily.lostAnyGame": + false, + + "daily.gaveCookies": + false, + + "daily.stoleCookies": + false + } + } + ); + } + + const set = {}; + + if ( + event.type === + "cookie_gain" + && + event.source === + "collect" + ) { + set[ + "daily.collectedCookies" + ] = true; + } + + if ( + event.type === + "cookies_given" + ) { + set[ + "daily.gaveCookies" + ] = true; + } + + if ( + event.type === + "steal_success" + ) { + set[ + "daily.stoleCookies" + ] = true; + } + + if ( + event.type === + "game_start" + && + document + ?.daily + ?.collectedCookies === true + ) { + set[ + "daily.gambledAfterCollect" + ] = true; + } + + if ( + Object.keys( + set + ).length === 0 + ) { + return; + } + + await stats.updateOne( + { + userId, + guildId + }, + { + $set: + set + } + ); +} module.exports = { ensureProfileSystem, @@ -3288,6 +3962,7 @@ module.exports = { getProfileCollections, getDefaultStats, getCompositeProgress, + updateBankruptcyRecovery, readStatValue, readStatPath, @@ -3316,4 +3991,7 @@ module.exports = { evaluateEventCondition, evaluateEventAchievement, evaluateEventAchievements, + evaluateSocialRelationshipFlags, + trackDailyGameState, + trackDailyNarrativeState, }; \ No newline at end of file