From 9710fc65bf96f333720ea4ec3c81e70a88e80e8b Mon Sep 17 00:00:00 2001 From: DeSqBlocki Date: Thu, 27 Aug 2026 19:34:16 +0200 Subject: [PATCH] snapshot migration phase 5 --- utils/profileMaintenance.js | 166 +++++++++++++++++ utils/profileSystem.js | 356 +++++++++++++++++++++++++++++++++++- 2 files changed, 518 insertions(+), 4 deletions(-) diff --git a/utils/profileMaintenance.js b/utils/profileMaintenance.js index 0796b5c..2275434 100644 --- a/utils/profileMaintenance.js +++ b/utils/profileMaintenance.js @@ -896,6 +896,172 @@ async function auditProfileData( 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; + } /* * ---------------------------------------------------- diff --git a/utils/profileSystem.js b/utils/profileSystem.js index af18ad4..1209aca 100644 --- a/utils/profileSystem.js +++ b/utils/profileSystem.js @@ -706,6 +706,14 @@ async function trackProfileEvent( updatedStats ); + + await evaluateEventAchievements( + interaction, + userId, + event + ); + + await evaluateBadges( interaction, userId, @@ -713,6 +721,7 @@ async function trackProfileEvent( event ); + return updatedStats; } @@ -977,8 +986,12 @@ async function evaluateAchievements( const normalDefinitions = definitions.filter( definition => - definition.measure !== - "achievementPercentage" + ![ + "achievementPercentage", + "event" + ].includes( + definition.measure + ) ); for (const definition of normalDefinitions) { @@ -2837,6 +2850,9 @@ function isStatBackfillableAchievement( if ( definition.measure === "achievementPercentage" + || + definition.measure === + "event" ) { return false; } @@ -2849,7 +2865,8 @@ function isStatBackfillableAchievement( return ( Array.isArray( definition.conditions - ) && + ) + && definition.conditions.length > 0 ); } @@ -2937,6 +2954,331 @@ function getCompositeProgress( conditions.length }; } +function buildEventContext( + interaction, + event +) { + const activity = + getActivityDateContext(); + + return { + ...event, + + localDate: + activity.dateKey, + + localWeekday: + activity.weekday, + + localHour: + activity.hour, + + localWeekKey: + activity.weekKey, + + localTimeBucket: + activity.timeBucket, + + isWeekend: + activity.weekday === + "saturday" + || + activity.weekday === + "sunday", + + isWeekday: + ![ + "saturday", + "sunday" + ].includes( + activity.weekday + ) + }; +} +function readEventPath( + event, + path +) { + if (!path) { + return undefined; + } + + return String(path) + .split(".") + .reduce( + (current, key) => + current?.[key], + event + ); +} + +function evaluateEventCondition( + event, + condition +) { + if ( + !condition || + !condition.field + ) { + return false; + } + + const actual = + readEventPath( + event, + condition.field + ); + + const expected = + condition.value; + + const operator = + String( + condition.operator ?? + "eq" + ).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 "in": + return ( + Array.isArray( + expected + ) + && + expected.includes( + actual + ) + ); + + + case "not_in": + return ( + Array.isArray( + expected + ) + && + !expected.includes( + actual + ) + ); + + + case "truthy": + return Boolean( + actual + ); + + + case "falsy": + return !Boolean( + actual + ); + + + default: + return false; + } +} +function evaluateEventAchievement( + event, + definition +) { + const conditions = + Array.isArray( + definition.eventConditions + ) + ? definition.eventConditions + : []; + + + if (!conditions.length) { + return false; + } + + + const mode = + String( + definition.eventMode ?? + "all" + ).toLowerCase(); + + + if (mode === "any") { + return conditions.some( + condition => + evaluateEventCondition( + event, + condition + ) + ); + } + + + return conditions.every( + condition => + evaluateEventCondition( + event, + condition + ) + ); +} +async function evaluateEventAchievements( + interaction, + userId, + event +) { + const guildId = + interaction.guildId; + + + if ( + !guildId || + !event?.type + ) { + return; + } + + + const { + achievements, + achievementConfig + } = + getProfileCollections(); + + + await ensureAchievementDocument( + userId, + guildId + ); + + + const definitions = + await achievementConfig + .find({ + active: { + $ne: false + }, + + measure: + "event" + }) + .toArray(); + + + if (!definitions.length) { + return; + } + + + const achievementDocument = + await achievements.findOne({ + userId, + guildId + }); + + + const unlocked = + achievementDocument + ?.unlocked ?? {}; + + + const eventContext = + buildEventContext( + interaction, + event + ); + + + for ( + const definition + of definitions + ) { + if ( + unlocked[ + definition.achievementId + ] + ) { + continue; + } + + + /* + * Optional fast filter. + * + * This avoids evaluating Roulette conditions + * against a Cookie Bakery event, for example. + */ + + if ( + definition.eventType && + definition.eventType !== + eventContext.type + ) { + continue; + } + + + if ( + !evaluateEventAchievement( + eventContext, + definition + ) + ) { + continue; + } + + + await unlockAchievement( + interaction, + userId, + guildId, + definition + ); + } +} module.exports = { ensureProfileSystem, @@ -2967,5 +3309,11 @@ module.exports = { getProfileContext, getProfileContextFromFooter, getProfileTargetId, - requireProfileOwner + requireProfileOwner, + + buildEventContext, + readEventPath, + evaluateEventCondition, + evaluateEventAchievement, + evaluateEventAchievements, }; \ No newline at end of file