diff --git a/utils/profileMaintenance.js b/utils/profileMaintenance.js index 4fb5f11..0796b5c 100644 --- a/utils/profileMaintenance.js +++ b/utils/profileMaintenance.js @@ -1,13 +1,15 @@ const { getProfileCollections, getProfileStats, - readStatValue + achievementRequirementsMet, + isStatBackfillableAchievement } = require("./profileSystem"); const { getUnixTime } = require("./cookieEconomy"); + function getActiveDefinitions( achievementConfig ) { @@ -20,6 +22,7 @@ function getActiveDefinitions( .toArray(); } + async function reconcileUserAchievements({ guildId, userId, @@ -39,9 +42,11 @@ async function reconcileUserAchievements({ userId, guildId ), + getActiveDefinitions( achievementConfig ), + achievements.findOne({ userId, guildId @@ -53,8 +58,16 @@ async function reconcileUserAchievements({ }; const additions = {}; + let skippedEventOnly = 0; + + /* + * ======================================================== + * NORMAL / STAT-BACKED ACHIEVEMENTS + * ======================================================== + */ + const normalDefinitions = definitions.filter( definition => @@ -62,94 +75,143 @@ async function reconcileUserAchievements({ "achievementPercentage" ); + for (const definition of normalDefinitions) { - if (unlocked[definition.achievementId]) { - continue; - } - - if (!definition.stat) { - skippedEventOnly++; - continue; - } - - const currentValue = - readStatValue( - statsDocument, - definition - ); - - if ( - currentValue < - Number(definition.target ?? 0) - ) { - continue; - } - - const timestamp = - getUnixTime(); - - additions[ - definition.achievementId - ] = timestamp; - - unlocked[ - definition.achievementId - ] = timestamp; - } - - const collectibleDefinitions = - definitions.filter( - definition => - definition.measure !== - "achievementPercentage" - ); - - const unlockedCollectible = - collectibleDefinitions.filter( - definition => - Boolean( - unlocked[ - definition.achievementId - ] - ) - ).length; - - const percentage = - collectibleDefinitions.length > 0 - ? unlockedCollectible / - collectibleDefinitions.length - : 0; - - for ( - const definition - of definitions.filter( - item => - item.measure === - "achievementPercentage" - ) - ) { if ( unlocked[ - definition.achievementId + definition.achievementId ] ) { continue; } if ( - percentage < - Number(definition.target ?? 0) + !isStatBackfillableAchievement( + definition + ) + ) { + skippedEventOnly++; + continue; + } + + + if ( + !achievementRequirementsMet( + statsDocument, + definition + ) ) { continue; } + + const timestamp = + getUnixTime(); + + additions[ definition.achievementId - ] = getUnixTime(); + ] = 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); + Object.entries( + additions + ); + if ( !dryRun && @@ -158,7 +220,10 @@ async function reconcileUserAchievements({ const set = {}; for ( - const [achievementId, timestamp] + const [ + achievementId, + timestamp + ] of additionEntries ) { set[ @@ -176,22 +241,53 @@ async function reconcileUserAchievements({ 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]) => @@ -200,6 +296,7 @@ async function reconcileUserAchievements({ }; } + async function reconcileAllAchievements({ guildId, dryRun = false @@ -208,6 +305,7 @@ async function reconcileAllAchievements({ stats } = getProfileCollections(); + const userIds = await stats.distinct( "userId", @@ -216,16 +314,21 @@ async function reconcileAllAchievements({ } ); + const summary = { users: userIds.length, + unlocked: 0, + skippedEventOnly: 0, + results: [] }; + for (const userId of userIds) { const result = await reconcileUserAchievements({ @@ -234,20 +337,25 @@ async function reconcileAllAchievements({ dryRun }); + summary.unlocked += result.unlocked; + summary.skippedEventOnly += result.skippedEventOnly; + summary.results.push( result ); } + return summary; } + async function auditProfileData( guildId ) { @@ -258,55 +366,94 @@ async function auditProfileData( badges } = getProfileCollections(); + const [ statDocuments, achievementDocuments, - activeAchievements, + activeDefinitions, badgeDocuments ] = await Promise.all([ stats.find({ guildId }).toArray(), + achievements.find({ guildId }).toArray(), - achievementConfig.countDocuments({ - active: { - $ne: false - } - }), + + 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); + Number( + total.played ?? 0 + ); + const resolved = - Number(total.won ?? 0) + - Number(total.lost ?? 0) + - Number(total.pushed ?? 0); + Number( + total.won ?? 0 + ) + + + Number( + total.lost ?? 0 + ) + + + Number( + total.pushed ?? 0 + ); - if (played !== resolved) { + + if ( + played !== + resolved + ) { issues.push( `${document.userId}: games.total.played (${played}) does not match W/L/P total (${resolved}).` ); } + for ( const game of [ @@ -319,13 +466,26 @@ async function auditProfileData( const value = document.games?.[game] ?? {}; + const gamePlayed = - Number(value.played ?? 0); + Number( + value.played ?? 0 + ); + const gameResolved = - Number(value.won ?? 0) + - Number(value.lost ?? 0) + - Number(value.pushed ?? 0); + Number( + value.won ?? 0 + ) + + + Number( + value.lost ?? 0 + ) + + + Number( + value.pushed ?? 0 + ); + if ( gamePlayed !== @@ -337,27 +497,627 @@ async function auditProfileData( } } + + /* + * ---------------------------------------------------- + * NON-NEGATIVE ECONOMY COUNTERS + * ---------------------------------------------------- + */ + const nonNegativeFields = [ - document.economy?.cookiesObtainedTotal, - document.economy?.cookiesLostTotal, - document.economy?.cookiesGivenTotal, - document.economy?.stolenTotal, - document.economy?.stolenFromMeTotal, - document.economy?.vaultStolenTotal + 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 + 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; + } + + + /* + * ---------------------------------------------------- + * 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( @@ -366,6 +1126,7 @@ async function auditProfileData( ) ); + const orphanAchievementDocs = achievementDocuments.filter( document => @@ -374,30 +1135,111 @@ async function auditProfileData( ) ).length; + + /* + * ======================================================== + * BADGES + * ======================================================== + */ + const claimedBadges = badgeDocuments.filter( badge => - Boolean(badge.ownerId) + Boolean( + badge.ownerId + ) ).length; + return { statDocuments: statDocuments.length, + achievementDocuments: achievementDocuments.length, - activeAchievements, + + 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 -}; +}; \ No newline at end of file diff --git a/utils/profileSystem.js b/utils/profileSystem.js index 93eb4b1..af18ad4 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -986,15 +986,11 @@ async function evaluateAchievements( continue; } - const currentValue = - readStatValue( + if ( + !achievementRequirementsMet( statsDocument, definition - ); - - if ( - currentValue < - Number(definition.target) + ) ) { continue; } @@ -1033,9 +1029,9 @@ async function evaluateAchievements( const collectibleDefinitions = definitions.filter( definition => - definition.measure !== - "achievementPercentage" && - Boolean(definition.stat) + isStatBackfillableAchievement( + definition + ) ); const totalCollectible = @@ -2133,7 +2129,23 @@ async function buildAchievementsEmbed( progressText = ` • **${Math.min(100, currentValue * 100).toFixed(0)}% / ${Math.min(100, targetValue * 100).toFixed(0)}%**`; } + else if ( + achievement.measure === "all" || + achievement.measure === "any" + ) { + const { + completed, + total + } = + getCompositeProgress( + statsDocument, + achievement + ); + + progressText = + ` • **${completed} / ${total}**`; + } else if (achievement.stat) { currentValue = readStatValue( @@ -2145,6 +2157,8 @@ async function buildAchievementsEmbed( ` • **${Number(currentValue).toLocaleString()} / ${targetValue.toLocaleString()}**`; } + + const requirement = achievement.requirement ?? achievement.description ?? @@ -2590,7 +2604,9 @@ function formatCategoryName( collector: "🏆 Collector", other: - "🏅 Other" + "🏅 Other", + activity: + "📅 Activity", }; return labels[category] ?? @@ -2637,6 +2653,291 @@ function formatBadgeLine( ].join("\n"); } +function readStatPath( + document, + stat +) { + if (!stat) { + return undefined; + } + + return String(stat) + .split(".") + .reduce( + (current, key) => + current?.[key], + document + ); +} + + +function evaluateCondition( + document, + condition +) { + if ( + !condition || + !condition.stat + ) { + return false; + } + + const actual = + readStatPath( + document, + condition.stat + ); + + const expected = + condition.value; + + const operator = + String( + condition.operator ?? + "gte" + ).toLowerCase(); + + + switch (operator) { + case "eq": + return ( + actual === + expected + ); + + + case "neq": + return ( + actual !== + expected + ); + + + case "gt": + return ( + Number(actual ?? 0) > + Number(expected ?? 0) + ); + + + case "gte": + return ( + Number(actual ?? 0) >= + Number(expected ?? 0) + ); + + + case "lt": + return ( + Number(actual ?? 0) < + Number(expected ?? 0) + ); + + + case "lte": + return ( + Number(actual ?? 0) <= + Number(expected ?? 0) + ); + + + case "includes": + return ( + Array.isArray(actual) && + actual.includes( + expected + ) + ); + + + case "not_includes": + return ( + Array.isArray(actual) && + !actual.includes( + expected + ) + ); + + + case "truthy": + return Boolean( + actual + ); + + + case "falsy": + return !Boolean( + actual + ); + + + default: + return false; + } +} + + +function evaluateCompositeAchievement( + document, + definition +) { + const conditions = + Array.isArray( + definition.conditions + ) + ? definition.conditions + : []; + + + if (!conditions.length) { + return false; + } + + + if ( + definition.measure === + "all" + ) { + return conditions.every( + condition => + evaluateCondition( + document, + condition + ) + ); + } + + + if ( + definition.measure === + "any" + ) { + return conditions.some( + condition => + evaluateCondition( + document, + condition + ) + ); + } + + + return false; +} + + +function isStatBackfillableAchievement( + definition +) { + if (!definition) { + return false; + } + + + if ( + definition.measure === + "achievementPercentage" + ) { + return false; + } + + + if ( + definition.measure === "all" || + definition.measure === "any" + ) { + return ( + Array.isArray( + definition.conditions + ) && + definition.conditions.length > 0 + ); + } + + + return Boolean( + definition.stat + ); +} + + +function achievementRequirementsMet( + document, + definition +) { + if (!definition) { + return false; + } + + + if ( + definition.measure === "all" || + definition.measure === "any" + ) { + return evaluateCompositeAchievement( + document, + definition + ); + } + + + if (!definition.stat) { + return false; + } + + + const currentValue = + readStatValue( + document, + definition + ); + + + return ( + currentValue >= + Number( + definition.target ?? 0 + ) + ); +} + +function getCompositeProgress( + statsDocument, + definition +) { + const conditions = + Array.isArray( + definition.conditions + ) + ? definition.conditions + : []; + + + if (!conditions.length) { + return { + completed: 0, + total: 0 + }; + } + + + const completed = + conditions.filter( + condition => + evaluateCondition( + statsDocument, + condition + ) + ).length; + + + return { + completed, + total: + conditions.length + }; +} + module.exports = { ensureProfileSystem, trackProfileEvent, @@ -2644,7 +2945,15 @@ module.exports = { getProfileStats, getProfileCollections, getDefaultStats, + getCompositeProgress, + readStatValue, + readStatPath, + + evaluateCondition, + evaluateCompositeAchievement, + achievementRequirementsMet, + isStatBackfillableAchievement, getActivityDateContext, trackGameActivity, @@ -2659,4 +2968,4 @@ module.exports = { getProfileContextFromFooter, getProfileTargetId, requireProfileOwner -}; +}; \ No newline at end of file