const { getProfileCollections, getProfileStats, achievementRequirementsMet, isStatBackfillableAchievement } = require("./profileSystem"); const { getUnixTime } = require("./cookieEconomy"); function getActiveDefinitions( achievementConfig ) { return achievementConfig .find({ active: { $ne: false } }) .toArray(); } async function reconcileUserAchievements({ guildId, userId, dryRun = false }) { const { achievements, achievementConfig } = getProfileCollections(); const [ statsDocument, definitions, achievementDocument ] = await Promise.all([ getProfileStats( userId, guildId ), getActiveDefinitions( achievementConfig ), achievements.findOne({ userId, guildId }) ]); const unlocked = { ...(achievementDocument?.unlocked ?? {}) }; const additions = {}; let skippedEventOnly = 0; /* * ======================================================== * NORMAL / STAT-BACKED ACHIEVEMENTS * ======================================================== */ const normalDefinitions = definitions.filter( definition => definition.measure !== "achievementPercentage" ); for (const definition of normalDefinitions) { if ( unlocked[ definition.achievementId ] ) { continue; } if ( !isStatBackfillableAchievement( definition ) ) { skippedEventOnly++; continue; } if ( !achievementRequirementsMet( statsDocument, definition ) ) { continue; } const timestamp = getUnixTime(); additions[ definition.achievementId ] = timestamp; unlocked[ definition.achievementId ] = timestamp; } /* * ======================================================== * COLLECTOR ACHIEVEMENTS * ======================================================== * * Only stat-backed achievements count toward the * collectible denominator. * * This intentionally matches profileSystem.js. */ const collectibleDefinitions = definitions.filter( definition => isStatBackfillableAchievement( definition ) ); const unlockedCollectible = collectibleDefinitions.filter( definition => Boolean( unlocked[ definition.achievementId ] ) ).length; const percentage = collectibleDefinitions.length > 0 ? unlockedCollectible / collectibleDefinitions.length : 0; const collectorDefinitions = definitions.filter( definition => definition.measure === "achievementPercentage" ); for (const definition of collectorDefinitions) { if ( unlocked[ definition.achievementId ] ) { continue; } if ( percentage < Number( definition.target ?? 0 ) ) { continue; } const timestamp = getUnixTime(); additions[ definition.achievementId ] = timestamp; unlocked[ definition.achievementId ] = timestamp; } /* * ======================================================== * WRITE * ======================================================== */ const additionEntries = Object.entries( additions ); if ( !dryRun && additionEntries.length > 0 ) { const set = {}; for ( const [ achievementId, timestamp ] of additionEntries ) { set[ `unlocked.${achievementId}` ] = timestamp; } await achievements.updateOne( { userId, guildId }, { $setOnInsert: { userId, guildId }, $set: set }, { upsert: true } ); console.log( `[ProfileMaintenance] Reconciled ${additionEntries.length} achievement(s) for ${userId}: ${additionEntries .map( ([achievementId]) => achievementId ) .join(", ")}` ); } if ( dryRun && additionEntries.length > 0 ) { console.log( `[ProfileMaintenance] Dry run: ${userId} would unlock ${additionEntries.length} achievement(s): ${additionEntries .map( ([achievementId]) => achievementId ) .join(", ")}` ); } return { userId, checked: definitions.length, unlocked: additionEntries.length, skippedEventOnly, dryRun, achievementIds: additionEntries.map( ([achievementId]) => achievementId ) }; } async function reconcileAllAchievements({ guildId, dryRun = false }) { const { stats } = getProfileCollections(); const userIds = await stats.distinct( "userId", { guildId } ); const summary = { users: userIds.length, unlocked: 0, skippedEventOnly: 0, results: [] }; for (const userId of userIds) { const result = await reconcileUserAchievements({ guildId, userId, dryRun }); summary.unlocked += result.unlocked; summary.skippedEventOnly += result.skippedEventOnly; summary.results.push( result ); } return summary; } async function auditProfileData( guildId ) { const { stats, achievements, achievementConfig, badges } = getProfileCollections(); const [ statDocuments, achievementDocuments, activeDefinitions, badgeDocuments ] = await Promise.all([ stats.find({ guildId }).toArray(), achievements.find({ guildId }).toArray(), achievementConfig .find({ active: { $ne: false } }) .toArray(), badges.find({ guildId }).toArray() ]); const issues = []; /* * ======================================================== * STATS DOCUMENT VALIDATION * ======================================================== */ for (const document of statDocuments) { if (!document.userId) { issues.push( "Stats document missing userId." ); continue; } /* * ---------------------------------------------------- * GAME TOTALS * ---------------------------------------------------- */ const total = document.games?.total ?? {}; const played = Number( total.played ?? 0 ); const resolved = Number( total.won ?? 0 ) + Number( total.lost ?? 0 ) + Number( total.pushed ?? 0 ); if ( played !== resolved ) { issues.push( `${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).` ); } for ( const game of [ "slots", "blackjack", "roulette", "heist" ] ) { const value = document.games?.[game] ?? {}; const gamePlayed = Number( value.played ?? 0 ); const gameResolved = Number( value.won ?? 0 ) + Number( value.lost ?? 0 ) + Number( value.pushed ?? 0 ); if ( gamePlayed !== gameResolved ) { issues.push( `${document.userId}: games.${game}.played (${gamePlayed}) does not match W/L/P total (${gameResolved}).` ); } } /* * ---------------------------------------------------- * NON-NEGATIVE ECONOMY COUNTERS * ---------------------------------------------------- */ const nonNegativeFields = [ document.economy ?.cookiesObtainedTotal, document.economy ?.cookiesLostTotal, document.economy ?.cookiesGivenTotal, document.economy ?.stolenTotal, document.economy ?.stolenFromMeTotal, document.economy ?.vaultStolenTotal ]; if ( nonNegativeFields.some( value => Number( value ?? 0 ) < 0 ) ) { issues.push( `${document.userId}: one or more economy counters are negative.` ); } /* * ---------------------------------------------------- * ACTIVITY SCHEMA * ---------------------------------------------------- */ const activity = document.activity; if (!activity) { issues.push( `${document.userId}: activity object is missing.` ); continue; } const currentDailyStreak = Number( activity.currentDailyStreak ?? 0 ); const bestDailyStreak = Number( activity.bestDailyStreak ?? 0 ); if ( currentDailyStreak < 0 || bestDailyStreak < 0 ) { issues.push( `${document.userId}: activity streak counters cannot be negative.` ); } if ( currentDailyStreak > bestDailyStreak ) { issues.push( `${document.userId}: currentDailyStreak (${currentDailyStreak}) exceeds bestDailyStreak (${bestDailyStreak}).` ); } /* * ---------------------------------------------------- * DATE KEYS * ---------------------------------------------------- */ for ( const field of [ "lastActiveDate", "lastGameDate" ] ) { const value = activity[field]; if ( value !== null && value !== undefined && !isValidDateKey( value ) ) { issues.push( `${document.userId}: activity.${field} has invalid date key "${value}".` ); } } /* * ---------------------------------------------------- * WEEK KEY * ---------------------------------------------------- */ if ( activity.currentWeekKey !== null && activity.currentWeekKey !== undefined && !isValidWeekKey( activity.currentWeekKey ) ) { issues.push( `${document.userId}: activity.currentWeekKey has invalid value "${activity.currentWeekKey}".` ); } /* * ---------------------------------------------------- * WEEKDAY COUNTERS * ---------------------------------------------------- */ const gamesByWeekday = activity.gamesByWeekday ?? {}; const weekdays = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ]; for (const weekday of weekdays) { const value = Number( gamesByWeekday[ weekday ] ?? 0 ); if ( !Number.isFinite( value ) || value < 0 ) { issues.push( `${document.userId}: activity.gamesByWeekday.${weekday} is invalid.` ); } } /* * ---------------------------------------------------- * CURRENT WEEK DAYS * ---------------------------------------------------- */ const currentWeekDays = activity.currentWeekDays; if ( !Array.isArray( currentWeekDays ) ) { issues.push( `${document.userId}: activity.currentWeekDays is not an array.` ); } else { const uniqueDays = new Set( currentWeekDays ); if ( uniqueDays.size !== currentWeekDays.length ) { issues.push( `${document.userId}: activity.currentWeekDays contains duplicate weekdays.` ); } for ( const weekday of currentWeekDays ) { if ( !weekdays.includes( weekday ) ) { issues.push( `${document.userId}: activity.currentWeekDays contains invalid weekday "${weekday}".` ); } } } /* * ---------------------------------------------------- * TIME BUCKETS * ---------------------------------------------------- */ const timeBuckets = activity.gamesByTimeBucket ?? {}; for ( const bucket of [ "overnight", "morning", "afternoon", "evening" ] ) { const value = Number( timeBuckets[ bucket ] ?? 0 ); if ( !Number.isFinite( value ) || value < 0 ) { issues.push( `${document.userId}: activity.gamesByTimeBucket.${bucket} is invalid.` ); } } /* * ---------------------------------------------------- * SCHEMA VERSION * ---------------------------------------------------- */ if ( Number( document .profileSchemaVersion ?? 0 ) < 2 ) { issues.push( `${document.userId}: profileSchemaVersion is below 2.` ); } } /* * ======================================================== * ACHIEVEMENT DEFINITION VALIDATION * ======================================================== */ const achievementIds = new Set(); for ( const definition of activeDefinitions ) { /* * ---------------------------------------------------- * ACHIEVEMENT ID * ---------------------------------------------------- */ if ( !definition .achievementId ) { issues.push( "Active achievement definition missing achievementId." ); continue; } if ( achievementIds.has( definition.achievementId ) ) { issues.push( `Duplicate active achievementId: ${definition.achievementId}.` ); } achievementIds.add( definition.achievementId ); /* * ---------------------------------------------------- * COLLECTOR ACHIEVEMENTS * ---------------------------------------------------- */ if ( definition.measure === "achievementPercentage" ) { if ( definition.target === undefined ) { issues.push( `${definition.achievementId}: target is missing.` ); continue; } const target = Number( definition.target ); if ( !Number.isFinite( target ) || target < 0 || target > 1 ) { issues.push( `${definition.achievementId}: achievementPercentage target must be between 0 and 1.` ); } continue; } /* * ---------------------------------------------------- * EVENT ACHIEVEMENTS * ---------------------------------------------------- */ if ( definition.measure === "event" ) { if ( !definition.eventType || typeof definition.eventType !== "string" ) { issues.push( `${definition.achievementId}: event achievement has no eventType.` ); } if ( !Array.isArray( definition.eventConditions ) || definition.eventConditions.length === 0 ) { issues.push( `${definition.achievementId}: event achievement has no eventConditions.` ); continue; } const validEventOperators = [ "eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "truthy", "falsy" ]; for ( const [ index, condition ] of definition .eventConditions .entries() ) { if ( !condition || typeof condition !== "object" ) { issues.push( `${definition.achievementId}: event condition ${index + 1} is invalid.` ); continue; } if ( !condition.field ) { issues.push( `${definition.achievementId}: event condition ${index + 1} has no field.` ); } const operator = String( condition.operator ?? "eq" ).toLowerCase(); if ( !validEventOperators.includes( operator ) ) { issues.push( `${definition.achievementId}: event condition ${index + 1} has unknown operator "${operator}".` ); } if ( [ "eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in" ].includes( operator ) && condition.value === undefined ) { issues.push( `${definition.achievementId}: event condition ${index + 1} using "${operator}" is missing value.` ); } if ( [ "in", "not_in" ].includes( operator ) && !Array.isArray( condition.value ) ) { issues.push( `${definition.achievementId}: event condition ${index + 1} using "${operator}" requires an array value.` ); } if ( [ "gt", "gte", "lt", "lte" ].includes( operator ) && !Number.isFinite( Number( condition.value ) ) ) { issues.push( `${definition.achievementId}: event condition ${index + 1} has a non-numeric comparison value.` ); } } continue; } /* * ---------------------------------------------------- * COMPOSITE ACHIEVEMENTS * ---------------------------------------------------- */ if ( definition.measure === "all" || definition.measure === "any" ) { if ( !Array.isArray( definition.conditions ) || definition.conditions.length === 0 ) { issues.push( `${definition.achievementId}: composite achievement has no conditions.` ); continue; } const validOperators = [ "eq", "neq", "gt", "gte", "lt", "lte", "includes", "not_includes", "truthy", "falsy" ]; for ( const [ index, condition ] of definition .conditions .entries() ) { if ( !condition || typeof condition !== "object" ) { issues.push( `${definition.achievementId}: condition ${index + 1} is invalid.` ); continue; } if ( !condition.stat ) { issues.push( `${definition.achievementId}: condition ${index + 1} has no stat path.` ); } const operator = String( condition.operator ?? "gte" ).toLowerCase(); if ( !validOperators.includes( operator ) ) { issues.push( `${definition.achievementId}: condition ${index + 1} has unknown operator "${operator}".` ); } /* * Operators that compare against a value * require condition.value. */ if ( [ "eq", "neq", "gt", "gte", "lt", "lte", "includes", "not_includes" ].includes( operator ) && condition.value === undefined ) { issues.push( `${definition.achievementId}: condition ${index + 1} using "${operator}" is missing value.` ); } /* * Numeric operators should use numeric * comparison values. */ if ( [ "gt", "gte", "lt", "lte" ].includes( operator ) && !Number.isFinite( Number( condition.value ) ) ) { issues.push( `${definition.achievementId}: condition ${index + 1} using "${operator}" has a non-numeric value.` ); } } /* * Composite achievements use their conditions * instead of a top-level stat/target. */ continue; } /* * ---------------------------------------------------- * NORMAL STAT-BACKED ACHIEVEMENTS * ---------------------------------------------------- */ if ( definition.stat ) { if ( definition.target === undefined ) { issues.push( `${definition.achievementId}: target is missing.` ); continue; } const target = Number( definition.target ); if ( !Number.isFinite( target ) ) { issues.push( `${definition.achievementId}: target is not numeric.` ); } continue; } /* * ---------------------------------------------------- * EVENT-ONLY ACHIEVEMENTS * ---------------------------------------------------- * * These aren't considered structurally invalid. * * Stage 4 will introduce proper event-condition * validation for them. */ continue; } /* * ======================================================== * ORPHANED ACHIEVEMENT DOCUMENTS * ======================================================== */ const statUsers = new Set( statDocuments.map( document => document.userId ) ); const orphanAchievementDocs = achievementDocuments.filter( document => !statUsers.has( document.userId ) ).length; /* * ======================================================== * BADGES * ======================================================== */ const claimedBadges = badgeDocuments.filter( badge => Boolean( badge.ownerId ) ).length; return { statDocuments: statDocuments.length, achievementDocuments: achievementDocuments.length, activeAchievements: activeDefinitions.length, badgeDocuments: badgeDocuments.length, claimedBadges, orphanAchievementDocs, issueCount: issues.length, issues }; } function isValidDateKey( value ) { if ( typeof value !== "string" ) { return false; } if ( !/^\d{4}-\d{2}-\d{2}$/.test( value ) ) { return false; } const date = new Date( `${value}T00:00:00Z` ); if ( Number.isNaN( date.getTime() ) ) { return false; } return ( date .toISOString() .slice( 0, 10 ) === value ); } function isValidWeekKey( value ) { return ( typeof value === "string" && /^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])$/.test( value ) ); } module.exports = { reconcileUserAchievements, reconcileAllAchievements, auditProfileData };