import moment from 'moment'; import mongoose from 'mongoose'; import nconf from 'nconf'; import { model as User } from '../models/user'; import * as Tasks from '../models/task'; import { model as Group } from '../models/group'; import common from '../../common'; import { preenUserHistory } from './preening'; import { revealMysteryItems } from './payments/subscriptions'; import { model as UserHistory } from '../models/userHistory'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true'; const { MAX_INCENTIVES } = common.constants; const { shouldDo, i18n, getPlanContext, } = common; const { scoreTask } = common.ops; const { loginIncentives } = common.content; function setIsDueNextDue (task, user, now) { const optionsForShouldDo = { dayStart: user.preferences.dayStart, timezoneOffset: user.preferences.timezoneOffset, }; task.isDue = common.shouldDo(now, task, optionsForShouldDo); optionsForShouldDo.nextDue = true; const nextDue = common.shouldDo(now, task, optionsForShouldDo); if (nextDue && nextDue.length > 0) { task.nextDue = nextDue.map(dueDate => dueDate.toISOString()); } } async function unlockUser (user) { await User.updateOne({ _id: user._id, }, { _cronSignature: 'NOT_RUNNING', }).exec(); } async function grantEndOfTheMonthPerks (user, now) { const { plan, elapsedMonths } = getPlanContext(user, now); if (elapsedMonths > 0) { plan.dateUpdated = now; // Award mystery items revealMysteryItems(user, elapsedMonths); plan.consecutive.count += elapsedMonths; plan.cumulativeCount += elapsedMonths; await plan.rewardPerks(user._id, elapsedMonths); } } function removeTerminatedSubscription (user) { const { plan } = user.purchased; plan.planId = null; plan.customerId = null; plan.subscriptionId = null; plan.paymentMethod = null; plan.consecutive.count = 0; user.markModified('purchased.plan'); } function processHabits (user, habits, now, daysMissed) { // check if we've passed a day on which we should reset the habit counters, including today const nowMoment = moment(now) .utcOffset(user.getUtcOffset() - user.preferences.dayStart * 60); const thatDay = nowMoment.clone() .subtract({ days: daysMissed }); const resetWeekly = nowMoment.isoWeek() !== thatDay.isoWeek(); const resetMonthly = nowMoment.month() !== thatDay.month(); habits.forEach(task => { // reset counters if appropriate let reset = false; if (task.frequency === 'daily') { reset = true; } else if (task.frequency === 'weekly' && resetWeekly === true) { reset = true; } else if (task.frequency === 'monthly' && resetMonthly === true) { reset = true; } if (reset === true) { task.counterUp = 0; task.counterDown = 0; } // slowly reset value to 0 for "onlies" (Habits with + or - but not both) // move singleton Habits towards yellow. if (task.up === false || task.down === false) { task.value = Math.abs(task.value) < 0.1 ? 0 : task.value /= 2; } }); } function trackCronAnalytics (analytics, user, _progress, options) { analytics.track('Cron', { category: 'behavior', gaLabel: 'Cron Count', gaValue: user.flags.cronCount, uuid: user._id, user, resting: user.preferences.sleep, cronCount: user.flags.cronCount, progressUp: Math.min(_progress.up, 900), progressDown: _progress.down, headers: options.headers, loginIncentives: user.loginIncentives, }); if ( user.party && user.party.quest && !user.party.quest.RSVPNeeded && !user.party.quest.completed && user.party.quest.key && !user.preferences.sleep ) { analytics.track('quest participation', { category: 'behavior', uuid: user._id, user, questName: user.party.quest.key, headers: options.headers, }, true); } } function awardLoginIncentives (user) { if (user.loginIncentives > MAX_INCENTIVES) return; // Remove old notifications if they exists user.notifications.forEach((notif, index) => { if (notif && notif.type === 'LOGIN_INCENTIVE') user.notifications.splice(index, 1); }); const notificationData = {}; notificationData.message = i18n.t('checkinEarned', user.preferences.language); const loginIncentive = loginIncentives[user.loginIncentives]; if (loginIncentive.rewardKey) { loginIncentive.assignReward(user); notificationData.reward = loginIncentive.reward; notificationData.rewardText = ''; // @TODO: Abstract this logic and share it across the server and client let count = 0; for (const reward of loginIncentive.reward) { if (reward.text) { notificationData.rewardText += reward.text(user.preferences.language); if (reward.key === 'RoyalPurple') { notificationData.rewardText = i18n.t('potion', { potionType: notificationData.rewardText }, user.preferences.language); } } else if (loginIncentive.rewardKey[0] === 'background_blue') { notificationData.rewardText = i18n.t('incentiveBackgrounds', user.preferences.language); } if (loginIncentive.reward.length > 0 && count < loginIncentive.reward.length - 1) notificationData.rewardText += ', '; count += 1; } // Overwrite notificationData.rewardText if rewardName was explicitly declared if (loginIncentive.rewardName) { notificationData.rewardText = i18n.t(loginIncentive.rewardName, user.preferences.language); } notificationData.rewardKey = loginIncentive.rewardKey; notificationData.message = i18n.t('unlockedCheckInReward', user.preferences.language); } notificationData.nextRewardAt = loginIncentives[user.loginIncentives].nextRewardAt || 0; user.addNotification('LOGIN_INCENTIVE', notificationData); } // Perform various beginning-of-day reset actions. export async function cron (options = {}) { const { user, tasksByType, analytics, now = new Date(), daysMissed, timezoneUtcOffsetFromUserPrefs, } = options; let _progress = { down: 0, up: 0, collectedItems: 0 }; user.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetFromUserPrefs; // User is only allowed a certain number of drops a day. This resets the count. if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; // "Perfect Day" achievement for perfect days let perfect = true; // Reset Gold-to-Gems cap if it's the start of the month const dateUpdatedFalse = !moment(user.purchased.plan.dateUpdated).startOf('month').isSame(moment().startOf('month')) || !user.purchased.plan.dateUpdated; if (user.purchased && user.purchased.plan && dateUpdatedFalse) { user.purchased.plan.gemsBought = 0; if (!user.purchased.plan.dateUpdated) user.purchased.plan.dateUpdated = moment(); } if (user.isSubscribed()) { await grantEndOfTheMonthPerks(user, now); } const { plan } = user.purchased; const userHasTerminatedSubscription = plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date()); if (!CRON_SAFE_MODE && userHasTerminatedSubscription) removeTerminatedSubscription(user); // Login Incentives user.loginIncentives += 1; awardLoginIncentives(user); const multiDaysCountAsOneDay = true; // If the user does not log in for two or more days, // cron (mostly) acts as if it were only one day. // When site-wide difficulty settings are introduced, this can be a user preference option. let todoTally = 0; // make uncompleted To Do's redder (further incentive to complete them) tasksByType.todos.forEach(task => { if ( task.completed || (task.group.assignedDate && moment(task.group.assignedDate).isAfter(user.auth.timestamps.updated)) ) return; scoreTask({ task, user, direction: 'down', cron: true, times: multiDaysCountAsOneDay ? 1 : daysMissed, }); todoTally += task.value; }); user.history.todos.push({ date: now.toISOString(), value: todoTally }); // For incomplete Dailys, add value (further incentive), // deduct health, keep records for later decreasing the nightly mana gain. // The negative effects are not done when resting in the inn. let dailyChecked = 0; // how many dailies were checked? let dailyDueUnchecked = 0; // how many dailies were un-checked? let atLeastOneDailyDue = false; // were any dailies due? if (!user.party.quest.progress.down) user.party.quest.progress.down = 0; tasksByType.dailys.forEach(task => { const isTeamBoardTask = task.group.id && !task.userId; if ( !isTeamBoardTask && task.group.assignedDate && moment(task.group.assignedDate).isAfter(user.auth.timestamps.updated) ) return; const { completed } = task; // Deduct points for missed Daily tasks let evadeTask = 0; let scheduleMisses = 0; if (completed) { if (!isTeamBoardTask) dailyChecked += 1; if (!atLeastOneDailyDue) { // only bother checking until the first thing is found atLeastOneDailyDue = task.isDue; } } else { // dailys repeat, so need to calculate how many they've missed according to their own schedule for (let i = 0; i < daysMissed; i += 1) { const thatDay = moment(now).subtract({ days: i + 1 }); if (shouldDo(thatDay.toDate(), task, user.preferences)) { atLeastOneDailyDue = true; scheduleMisses += 1; if (user.stats.buffs.stealth && !isTeamBoardTask) { user.stats.buffs.stealth -= 1; evadeTask += 1; } } if (multiDaysCountAsOneDay) break; } if (scheduleMisses > evadeTask) { // The user did not complete this due Daily // (but no penalty if cron is running in safe mode). if (CRON_SAFE_MODE) { dailyChecked += 1; // allows full allotment of mp to be gained } else { perfect = false; // Partially completed checklists dock fewer mana points if (task.checklist && task.checklist.length > 0) { const completedItems = task.checklist.filter(i => i.completed).length; const fractionChecked = completedItems / task.checklist.length; dailyDueUnchecked += 1 - fractionChecked; dailyChecked += fractionChecked; } else { dailyDueUnchecked += 1; } if (!user.preferences.sleep) { const delta = scoreTask({ user, task, direction: 'down', times: multiDaysCountAsOneDay ? 1 : scheduleMisses - evadeTask, cron: true, }); if (!CRON_SEMI_SAFE_MODE) { // Apply damage from a boss, less damage for Trivial priority (difficulty) user.party.quest.progress.down += delta * (task.priority < 1 ? task.priority : 1); // NB: Medium and Hard priorities do not increase damage from boss. // This was by accident // initially, and when we realised, we could not fix it because users are used to // their Medium and Hard Dailies doing an Easy amount of damage from boss. // Easy is task.priority = 1. Anything < 1 will be Trivial (0.1) or any future // setting between Trivial and Easy. } } } } // add history entry when task was not completed task.history.push({ date: Number(new Date()), value: task.value, isDue: task.isDue, completed: false, }); } task.completed = false; setIsDueNextDue(task, user, now); if (completed || scheduleMisses > 0) { if (task.checklist) { task.checklist.forEach(i => { i.completed = false; }); } } }); processHabits(user, tasksByType.habits, now, daysMissed); // tally experience let expTally = user.stats.exp; let lvl = 0; // iterator while (lvl < user.stats.lvl - 1) { lvl += 1; expTally += common.tnl(lvl); } user.history.exp.push({ date: now.toISOString(), value: expTally }); // Remove any remaining completed todos from the list of active todos const incompleteTodoIds = tasksByType.todos.filter(task => !task.completed).map(task => task._id); user.tasksOrder.todos = user.tasksOrder.todos .filter(taskOrderId => incompleteTodoIds.includes(taskOrderId)); // TODO also adjust tasksOrder arrays to remove deleted tasks of any kind (including rewards), ensure that all existing tasks are in the arrays, no tasks IDs are duplicated -- https://github.com/HabitRPG/habitica/issues/7645 // preen user history so that it doesn't become a performance problem // also for subscribed users but differently preenUserHistory(user, tasksByType); if (perfect && atLeastOneDailyDue) { user.achievements.perfect += 1; const lvlDiv2 = Math.ceil(common.capByLevel(user.stats.lvl) / 2); user.stats.buffs = { str: lvlDiv2, int: lvlDiv2, per: lvlDiv2, con: lvlDiv2, stealth: 0, streaks: false, }; } else { user.stats.buffs = { str: 0, int: 0, per: 0, con: 0, stealth: 0, streaks: false, }; } common.setDebuffPotionItems(user); // Add 10 MP, or 10% of max MP if that'd be more. // Perform this after Perfect Day for maximum benefit // Adjust for fraction of dailies completed if (!user.preferences.sleep) { if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; const { maxMP } = common.statsComputed(user); user.stats.mp += (Math.max(10, 0.1 * maxMP) * dailyChecked) / (dailyDueUnchecked + dailyChecked); // eslint-disable-line max-len if (user.stats.mp > maxMP) { user.stats.mp = maxMP; } // After all is said and done, // progress up user's effect on quest, return those values & reset the user's const { progress } = user.party.quest; _progress = progress.toObject(); // clone the old progress object progress.down = 0; progress.up = 0; progress.collectedItems = 0; } if (user.pinnedItems && user.pinnedItems.length > 0) { user.pinnedItems = common.cleanupPinnedItems(user); } // Analytics user.flags.cronCount += 1; trackCronAnalytics(analytics, user, _progress, options); await UserHistory.beginUserHistoryUpdate(user._id, options.headers) .withCron(user.flags.cronCount) .commit(); return _progress; } // Wait 5 minutes before attempting another cron const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime(); async function checkForActiveCron (user, now, session) { // set _cronSignature to current time in ms since epoch time // so we can make sure to wait at least CRONT_TIMEOUT_WAIT before attempting another cron const _cronSignature = now.getTime(); // Calculate how long ago cron must have been attempted to try again const cronRetryTime = _cronSignature - CRON_TIMEOUT_WAIT; // To avoid double cron we first set _cronSignature // and then check that it's not changed while processing const userUpdateResult = await User.updateOne({ _id: user._id, $or: [ // Make sure last cron was successful or failed before cronRetryTime { _cronSignature: 'NOT_RUNNING' }, { _cronSignature: { $lt: cronRetryTime } }, ], }, { $set: { _cronSignature, }, }, { session }).exec(); // If the cron signature is already set, cron is running in another request // throw an error and recover later, if (userUpdateResult.matchedCount === 0 || userUpdateResult.modifiedCount === 0) { throw new Error('CRON_ALREADY_RUNNING'); } } export async function cronWrapper (req, res) { const { user } = res.locals; if (!user) return null; // User might not be available when authentication is not mandatory const { analytics } = res; const now = new Date(); let session; try { await checkForActiveCron(user, now); const { daysMissed, timezoneUtcOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); if (daysMissed <= 0) { if (user.isModified()) { user._cronSignature = 'NOT_RUNNING'; await user.save(); } else { await unlockUser(user); } return null; } // Clear old completed todos - 30 days for free users, 90 for subscribers // Do not delete challenges completed todos TODO unless the task is broken? // Do not delete group completed todos await Tasks.Task.deleteMany({ userId: user._id, type: 'todo', completed: true, dateCompleted: { $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), }, 'challenge.id': { $exists: false }, 'group.id': { $exists: false }, }).exec(); const tasks = await Tasks.Task.find({ userId: user._id, $or: [ // Exclude completed todos { type: 'todo', completed: false }, { type: { $in: ['habit', 'daily'] } }, ], }, null).exec(); const tasksByType = { habits: [], dailys: [], todos: [], rewards: [], }; tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); // Run cron const progress = await cron({ user, tasksByType, now, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs, headers: req.headers, }); // await Group.tavernBoss(user, progress); // Save user and tasks user._cronSignature = 'NOT_RUNNING'; user.markModified('_cronSignature'); user.auth.timestamps.loggedin = now; user.lastCron = now; session = await mongoose.startSession(); await session.withTransaction(async () => { await user.save({ session }); for (const index in tasks) { if (Object.prototype.hasOwnProperty.call(tasks, index)) { const task = tasks[index]; // eslint-disable-next-line no-await-in-loop if (task.isModified()) await task.save({ session }); } } }); await Group.processQuestProgress(user, progress); // Reload user res.locals.user = await User.findOne({ _id: user._id }).exec(); return null; } catch (err) { if (err.message !== 'CRON_ALREADY_RUNNING') { // For any other error make sure to reset _cronSignature // so that it doesn't prevent cron from running // at the next request await unlockUser(user); } throw err; // re-throw the original error } finally { if (session) { await session.endSession(); } } }