603fc8c4dd
* remove commented-out code for purging PMs - no longer needed https://github.com/HabitRPG/habitica/issues/7940#issuecomment-406489506 * adjust comments * move cron code when sleeping / resting back into main body of cron code * rename tests to use consistent terminology for sleeping * add tests for cron when user is sleeping * move sleeping tests to same place as non-sleeping test This matches how the code has sleeping and non-sleeping code mingled. * replace a broken test with new tests The deleted test wasn't working correctly. The check that the user's health hadn't decreased would have worked even if the user wasn't sleeping because the Daily had been marked completed. The new tests test both no damage from incomplete Dailies and that Dailies are reset. * add tests for Perfect Day buff and rename existing tests for consistent terminology * remove old test code
474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
import moment from 'moment';
|
|
import { model as User } from '../models/user';
|
|
import common from '../../common/';
|
|
import { preenUserHistory } from '../libs/preening';
|
|
import sleep from '../libs/sleep';
|
|
import _ from 'lodash';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import nconf from 'nconf';
|
|
|
|
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.MAX_INCENTIVES;
|
|
const shouldDo = common.shouldDo;
|
|
const scoreTask = common.ops.scoreTask;
|
|
const i18n = common.i18n;
|
|
const loginIncentives = common.content.loginIncentives;
|
|
// const maxPMs = 200;
|
|
|
|
function setIsDueNextDue (task, user, now) {
|
|
let optionsForShouldDo = cloneDeep(user.preferences.toObject());
|
|
task.isDue = common.shouldDo(now, task, optionsForShouldDo);
|
|
optionsForShouldDo.nextDue = true;
|
|
let nextDue = common.shouldDo(now, task, optionsForShouldDo);
|
|
if (nextDue && nextDue.length > 0) {
|
|
task.nextDue = nextDue;
|
|
}
|
|
}
|
|
|
|
export async function recoverCron (status, locals) {
|
|
let {user} = locals;
|
|
|
|
await sleep(0.3);
|
|
|
|
let reloadedUser = await User.findOne({_id: user._id}).exec();
|
|
|
|
if (!reloadedUser) {
|
|
throw new Error(`User ${user._id} not found while recovering.`);
|
|
} else if (reloadedUser._cronSignature !== 'NOT_RUNNING') {
|
|
status.times++;
|
|
|
|
if (status.times < 5) {
|
|
await recoverCron(status, locals);
|
|
} else {
|
|
throw new Error(`Impossible to recover from cron for user ${user._id}.`);
|
|
}
|
|
} else {
|
|
locals.user = reloadedUser;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let CLEAR_BUFFS = {
|
|
str: 0,
|
|
int: 0,
|
|
per: 0,
|
|
con: 0,
|
|
stealth: 0,
|
|
streaks: false,
|
|
};
|
|
|
|
function grantEndOfTheMonthPerks (user, now) {
|
|
const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3; // multi-month subscriptions are for multiples of 3 months
|
|
let plan = user.purchased.plan;
|
|
let subscriptionEndDate = moment(plan.dateTerminated).isBefore() ? moment(plan.dateTerminated).startOf('month') : moment(now).startOf('month');
|
|
let dateUpdatedMoment = moment(plan.dateUpdated).startOf('month');
|
|
let elapsedMonths = moment(subscriptionEndDate).diff(dateUpdatedMoment, 'months');
|
|
|
|
if (elapsedMonths > 0) {
|
|
plan.dateUpdated = now;
|
|
// For every month, inc their "consecutive months" counter. Give perks based on consecutive blocks
|
|
// If they already got perks for those blocks (eg, 6mo subscription, subscription gifts, etc) - then dec the offset until it hits 0
|
|
_.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0});
|
|
|
|
let planMonthsLength = 1; // 1 for one-month recurring or gift subscriptions; later set to 3 for 3-month recurring, etc.
|
|
|
|
for (let i = 0; i < elapsedMonths; i++) {
|
|
plan.consecutive.count++;
|
|
|
|
plan.consecutive.offset--;
|
|
// If offset is now greater than 0, the user is within a period for which they have already been given the consecutive months perks.
|
|
//
|
|
// If offset now equals 0, this is the final month for which the user has already been given the consecutive month perks.
|
|
// We do not give them more perks yet because they might cancel the subscription before the next payment is taken.
|
|
//
|
|
// If offset is now less than 0, the user EITHER has a single-month recurring subscription and MIGHT be due for perks,
|
|
// OR has a multi-month subscription that renewed some time in the previous calendar month and so they are due for a new set of perks
|
|
// (strictly speaking, they should have been given the perks at the time that next payment was taken, but we don't have support for
|
|
// tracking payments like that - giving the perks when offset is < 0 is a workaround).
|
|
|
|
if (plan.consecutive.offset < 0) {
|
|
if (plan.planId) {
|
|
// NB gift subscriptions don't have a planID (which doesn't matter because we don't need to reapply perks for them and by this point they should have expired anyway)
|
|
let planIdRegExp = new RegExp('_([0-9]+)mo'); // e.g., matches 'google_6mo' / 'basic_12mo' and captures '6' / '12'
|
|
let match = plan.planId.match(planIdRegExp);
|
|
if (match !== null && match[0] !== null) {
|
|
planMonthsLength = match[1]; // 3 for 3-month recurring subscription, etc
|
|
}
|
|
}
|
|
|
|
let perkAmountNeeded = 0; // every 3 months you get one set of perks - this variable records how many sets you need
|
|
if (planMonthsLength === 1) {
|
|
// User has a single-month recurring subscription and are due for perks IF they've been subscribed for a multiple of 3 months.
|
|
if (plan.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH === 0) { // every 3 months
|
|
perkAmountNeeded = 1;
|
|
}
|
|
plan.consecutive.offset = 0; // allow the same logic to be run next month
|
|
} else {
|
|
// User has a multi-month recurring subscription and it renewed in the previous calendar month.
|
|
perkAmountNeeded = planMonthsLength / SUBSCRIPTION_BASIC_BLOCK_LENGTH; // e.g., for a 6-month subscription, give two sets of perks
|
|
plan.consecutive.offset = planMonthsLength - 1; // don't need to check for perks again for this many months (subtract 1 because we should have run this when the payment was taken last month)
|
|
}
|
|
if (perkAmountNeeded > 0) {
|
|
plan.consecutive.trinkets += perkAmountNeeded; // one Hourglass every 3 months
|
|
plan.consecutive.gemCapExtra += 5 * perkAmountNeeded; // 5 extra Gems every 3 months
|
|
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeTerminatedSubscription (user) {
|
|
let plan = user.purchased.plan;
|
|
|
|
_.merge(plan, {
|
|
planId: null,
|
|
customerId: null,
|
|
paymentMethod: null,
|
|
});
|
|
|
|
_.merge(plan.consecutive, {
|
|
count: 0,
|
|
offset: 0,
|
|
gemCapExtra: 0,
|
|
});
|
|
|
|
user.markModified('purchased.plan');
|
|
}
|
|
|
|
function resetHabitCounters (user, tasksByType, now, daysMissed) {
|
|
// check if we've passed a day on which we should reset the habit counters, including today
|
|
let resetWeekly = false;
|
|
let resetMonthly = false;
|
|
for (let i = 0; i < daysMissed; i++) {
|
|
if (resetWeekly === true && resetMonthly === true) {
|
|
break;
|
|
}
|
|
let thatDay = moment(now).zone(user.preferences.timezoneOffset + user.preferences.dayStart * 60).subtract({days: i});
|
|
if (thatDay.day() === 1) {
|
|
resetWeekly = true;
|
|
}
|
|
if (thatDay.date() === 1) {
|
|
resetMonthly = true;
|
|
}
|
|
}
|
|
|
|
tasksByType.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;
|
|
}
|
|
});
|
|
}
|
|
|
|
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: _.min([_progress.up, 900]),
|
|
progressDown: _progress.down,
|
|
headers: options.headers,
|
|
loginIncentives: user.loginIncentives,
|
|
});
|
|
}
|
|
|
|
function awardLoginIncentives (user) {
|
|
if (user.loginIncentives > MAX_INCENTIVES) return;
|
|
// A/B test 2016-12-21: Should we deliver notifications for upcoming incentives on days when users don't receive rewards?
|
|
if (!loginIncentives[user.loginIncentives].rewardKey && user._ABtests && user._ABtests.checkInModals === '20161221_noCheckInPreviews') return;
|
|
|
|
// Remove old notifications if they exists
|
|
user.notifications.forEach((notif, index) => {
|
|
if (notif && notif.type === 'LOGIN_INCENTIVE') user.notifications.splice(index, 1);
|
|
});
|
|
|
|
let notificationData = {};
|
|
notificationData.message = i18n.t('checkinEarned', user.preferences.language);
|
|
|
|
let 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 (let 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 function cron (options = {}) {
|
|
let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options;
|
|
let _progress = {down: 0, up: 0, collectedItems: 0};
|
|
|
|
// Record pre-cron values of HP and MP to show notifications later
|
|
let beforeCronStats = _.pick(user.stats, ['hp', 'mp']);
|
|
|
|
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
|
// 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
|
|
let 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()) {
|
|
grantEndOfTheMonthPerks(user, now);
|
|
}
|
|
|
|
let plan = user.purchased.plan;
|
|
let userHasTerminatedSubscription = plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date());
|
|
if (!CRON_SAFE_MODE && userHasTerminatedSubscription) removeTerminatedSubscription(user);
|
|
|
|
// Login Incentives
|
|
user.loginIncentives++;
|
|
awardLoginIncentives(user);
|
|
|
|
let 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.
|
|
|
|
// Tally each task
|
|
let todoTally = 0;
|
|
|
|
tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them)
|
|
scoreTask({
|
|
task,
|
|
user,
|
|
direction: 'down',
|
|
cron: true,
|
|
times: multiDaysCountAsOneDay ? 1 : daysMissed,
|
|
});
|
|
|
|
todoTally += task.value;
|
|
});
|
|
|
|
// 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) => {
|
|
let completed = task.completed;
|
|
// Deduct points for missed Daily tasks
|
|
let EvadeTask = 0;
|
|
let scheduleMisses = daysMissed;
|
|
|
|
if (completed) {
|
|
dailyChecked += 1;
|
|
if (!atLeastOneDailyDue) { // only bother checking until the first thing is found
|
|
let thatDay = moment(now).subtract({days: daysMissed});
|
|
atLeastOneDailyDue = shouldDo(thatDay.toDate(), task, user.preferences);
|
|
}
|
|
} else {
|
|
// dailys repeat, so need to calculate how many they've missed according to their own schedule
|
|
scheduleMisses = 0;
|
|
|
|
for (let i = 0; i < daysMissed; i++) {
|
|
let thatDay = moment(now).subtract({days: i + 1});
|
|
|
|
if (shouldDo(thatDay.toDate(), task, user.preferences)) {
|
|
atLeastOneDailyDue = true;
|
|
scheduleMisses++;
|
|
if (user.stats.buffs.stealth) {
|
|
user.stats.buffs.stealth--;
|
|
EvadeTask++;
|
|
}
|
|
}
|
|
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;
|
|
|
|
if (task.checklist && task.checklist.length > 0) { // Partially completed checklists dock fewer mana points
|
|
let fractionChecked = _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length;
|
|
dailyDueUnchecked += 1 - fractionChecked;
|
|
dailyChecked += fractionChecked;
|
|
} else {
|
|
dailyDueUnchecked += 1;
|
|
}
|
|
|
|
if (!user.preferences.sleep) {
|
|
let 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,
|
|
});
|
|
}
|
|
|
|
task.completed = false;
|
|
setIsDueNextDue(task, user, now);
|
|
|
|
if (completed || scheduleMisses > 0) {
|
|
if (task.checklist) {
|
|
task.checklist.forEach(i => i.completed = false);
|
|
}
|
|
}
|
|
});
|
|
|
|
resetHabitCounters(user, tasksByType, now, daysMissed);
|
|
|
|
tasksByType.habits.forEach((task) => {
|
|
// 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 = task.value / 2;
|
|
}
|
|
});
|
|
|
|
// Finished tallying
|
|
user.history.todos.push({date: now, value: todoTally});
|
|
|
|
// tally experience
|
|
let expTally = user.stats.exp;
|
|
let lvl = 0; // iterator
|
|
while (lvl < user.stats.lvl - 1) {
|
|
lvl++;
|
|
expTally += common.tnl(lvl);
|
|
}
|
|
|
|
user.history.exp.push({date: now, value: expTally});
|
|
|
|
// Remove any remaining completed todos from the list of active todos
|
|
user.tasksOrder.todos = user.tasksOrder.todos.filter(taskOrderId => {
|
|
return _.some(tasksByType.todos, taskType => {
|
|
return taskType._id === taskOrderId && taskType.completed === false;
|
|
});
|
|
});
|
|
// 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++;
|
|
let 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 = _.cloneDeep(CLEAR_BUFFS);
|
|
}
|
|
|
|
// 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;
|
|
user.stats.mp += _.max([10, 0.1 * common.statsComputed(user).maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked);
|
|
if (user.stats.mp > common.statsComputed(user).maxMP) user.stats.mp = common.statsComputed(user).maxMP;
|
|
}
|
|
|
|
// After all is said and done, progress up user's effect on quest, return those values & reset the user's
|
|
if (!user.preferences.sleep) {
|
|
let progress = user.party.quest.progress;
|
|
_progress = progress.toObject(); // clone the old progress object
|
|
_.merge(progress, {down: 0, up: 0, collectedItems: 0});
|
|
}
|
|
|
|
// Send notification for changes in HP and MP.
|
|
// First remove a possible previous cron notification because
|
|
// we don't want to flood the users with many cron notifications at once.
|
|
let oldCronNotif = user.notifications.find((notif, index) => {
|
|
if (notif && notif.type === 'CRON') {
|
|
user.notifications.splice(index, 1);
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
user.addNotification('CRON', {
|
|
hp: user.stats.hp - beforeCronStats.hp - (oldCronNotif ? oldCronNotif.data.hp : 0),
|
|
mp: user.stats.mp - beforeCronStats.mp - (oldCronNotif ? oldCronNotif.data.mp : 0),
|
|
});
|
|
|
|
// Analytics
|
|
user.flags.cronCount++;
|
|
trackCronAnalytics(analytics, user, _progress, options);
|
|
|
|
return _progress;
|
|
}
|