diff --git a/common/script/api-v3/preenHistory.js b/common/script/api-v3/preenHistory.js new file mode 100644 index 0000000000..44ed51862e --- /dev/null +++ b/common/script/api-v3/preenHistory.js @@ -0,0 +1,64 @@ +import moment from 'moment'; +import _ from 'lodash'; + +function _preen (newHistory, history, amount, groupBy) { + let groups = _.chain(history) + .groupBy(h => moment(h.date).format(groupBy)) + .sortBy((h, k) => k) + .value(); + + groups = groups.slice(-amount); + groups.pop(); + + _.each(groups, (group) => { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length, + }); + }); +} + +// Free users: +// Preen history for users with > 7 history entries +// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array +// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week +// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] +// +// Subscribers: +// TODO implement +export function preenHistory (history) { + // TODO remember to add this to migration + /* history = _.filter(history, function(h) { + return !!h; + }); */ + let newHistory = []; + + _preen(newHistory, history, 50, 'YYYY'); + _preen(newHistory, history, moment().format('MM'), 'YYYYMM'); + + let thisMonth = moment().format('YYYYMM'); + newHistory = newHistory.concat(history.filter(h => { + return moment(h.date).format('YYYYMM') === thisMonth; + })); + + return newHistory; +} + +export function preenUserHistory (user, tasksByType, minHistLen = 7) { + tasksByType.habits.concat(user.dailys).forEach((task) => { + if (task.history.length > minHistLen) { + task.history = preenHistory(user, task.history); + task.markModified('history'); + } + }); + + if (user.history.exp.length > minHistLen) { + user.history.exp = preenHistory(user, user.history.exp); + user.markModified('history.exp'); + } + + if (user.history.todos.length > minHistLen) { + user.history.todos = preenHistory(user, user.history.todos); + user.markModified('history.todos'); + } +} diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js index 91e872ee4b..25c59393b6 100644 --- a/common/script/api-v3/scoreTask.js +++ b/common/script/api-v3/scoreTask.js @@ -1,5 +1,4 @@ import _ from 'lodash'; -import moment from 'moment'; import { NotAuthorized, } from '../../../website/src/libs/api-v3/errors'; @@ -195,18 +194,11 @@ export default function scoreTask (options = {}, req = {}) { } _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); - // history - let th = task.history; - let thl = task.history.length; - - if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { - th[thl - 1].value = task.value; // TODO mark modified? - } else { - th.push({ - date: Number(new Date()), // TODO are we going to cast history entries? - value: task.value, - }); - } + // Add history entry, even more than 1 per day + task.history.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); } else if (task.type === 'daily') { if (cron) { delta += _changeTaskValue(user, task, direction, times, cron); diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js new file mode 100644 index 0000000000..3dc5dbbf5e --- /dev/null +++ b/website/src/middlewares/api-v3/cron.js @@ -0,0 +1,309 @@ +import _ from 'lodash'; +import { + daysSince, + shouldDo, +} from '../../../../common/script/cron'; +import common from '../../../../common'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; +import moment from 'moment'; +import Task from '../../models/task'; +// import Group from '../../models/group'; + +function _runCron (options = {}) { + let {user, tasks, tasksByType, analytics, now, daysMissed} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + // Reset the lastDrop count to zero + if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; + + // "Perfect Day" achievement for perfect-days + let perfect = true; + + let clearBuffs = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, + }; + + // end-of-month perks for subscribers + let plan = user.purchased.plan; + if (user.isSubscribed()) { + if (moment(plan.dateUpdated).format('MMYYYY') !== moment().format('MMYYYY')) { + plan.gemsBought = 0; // reset gem-cap + 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 + // TODO use month diff instead of ++ / --? + _.defaults(plan.consecutive, {count: 0, offset: 0, trinkets: 0, gemCapExtra: 0}); // FIXME see https://github.com/HabitRPG/habitrpg/issues/4317 + plan.consecutive.count++; + if (plan.consecutive.offset > 0) { + plan.consecutive.offset--; + } else if (plan.consecutive.count % 3 === 0) { // every 3 months + plan.consecutive.trinkets++; + plan.consecutive.gemCapExtra += 5; + if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; // cap it at 50 (hard 25 limit + extra 25) + } + } + + // If user cancelled subscription, we give them until 30day's end until it terminates + if (plan.dateTerminated && moment(plan.dateTerminated).isBefore(new Date())) { + _.merge(plan, { + planId: null, + customerId: null, + paymentMethod: null, + }); + + _.merge(plan.consecutive, { + count: 0, + offset: 0, + gemCapExtra: 0, + }); + + user.markModified('purchased.plan'); // TODO necessary? + } + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + user.stats.buffs = _.cloneDeep(clearBuffs); + + tasksByType.dailys.forEach((daily) => { + let completed = daily.completed; + let thatDay = moment(now).subtract({days: 1}); + + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + daily.checklist.forEach(box => box.completed = false); + } + daily.completed = false; + }); + + return; + } + + 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 todos redder + let completed = task.completed; + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + // TODO pass req for analytics? + }); + + let absVal = completed ? Math.abs(task.value) : task.value; + todoTally += absVal; + }); + + let dailyChecked = 0; // how many dailies were checked? + let dailyDueUnchecked = 0; // how many dailies were cun-hecked? + 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; + } 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)) { + scheduleMisses++; + if (user.stats.buffs.stealth) { + user.stats.buffs.stealth--; + EvadeTask++; + } + if (multiDaysCountAsOneDay) break; + } + } + + if (scheduleMisses > EvadeTask) { + 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; + } + + let delta = scoreTask({ + user, + task, + direction: 'down', + times: multiDaysCountAsOneDay ? 1 : scheduleMisses - EvadeTask, + cron: true, + }); + + // 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. + } + } + + task.history.push({ + date: Number(new Date()), + value: task.value, + }); + task.completed = false; + + if (completed || scheduleMisses > 0) { + task.checklist.forEach(i => i.completed = true); // FIXME this should not happen for grey tasks unless they are completed + } + }); + + tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + 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({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}); + + // preen user history so that it doesn't become a performance problem + // also for subscribed users but differentyly + // premium subscribers can keep their full history. + user.fns.preenUserHistory(tasks); + + if (perfect) { + 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(clearBuffs); + } + + // 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 + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + + if (dailyDueUnchecked === 0 && dailyChecked === 0) dailyChecked = 1; + user.stats.mp += _.max([10, 0.1 * user._statsComputed.maxMP]) * dailyChecked / (dailyDueUnchecked + dailyChecked); + if (user.stats.mp > user._statsComputed.maxMP) { + user.stats.mp = user._statsComputed.maxMP; + } + + // After all is said and done, progress up user's effect on quest, return those values & reset the user's + let progress = user.party.quest.progress; + let _progress = _.cloneDeep(progress); + _.merge(progress, {down: 0, up: 0}); + progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); + + + // Analytics + user.flags.cronCount++; + analytics.track('Cron', { + category: 'behavior', + gaLabel: 'Cron Count', + gaValue: user.flags.cronCount, + uuid: user._id, + user, // TODO is it really necessary passing the whole user object? + resting: user.preferences.sleep, + cronCount: user.flags.cronCount, + progressUp: _.min([_progress.up, 900]), + progressDown: _progress.down, + }); + + return _progress; +} + +// At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +// For incomplete Dailys, deduct experience +// Make sure to run this function once in a while as server will not take care of overnight calculations. +// And you have to run it every time client connects. +export default function cron (req, res, next) { + let user = res.locals.user; + let analytics = res.analytics; + + let now = new Date(); + let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences)); + + if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here? + + // Fetch active tasks (no completed todos) + Task.find({ + userId: user._id, + $or: [ // Exclude completed todos + {type: 'todo', completed: false}, + {type: {$in: ['habit', 'daily', 'reward']}}, + ], + }).exec() + .then((tasks) => { + let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; + tasks.forEach(task => tasksByType[`${task.type}s`].push(task)); + + // Run cron + _runCron({user, tasks, tasksByType, now, daysMissed, analytics}); + + let ranCron = user.isModified(); + let quest = common.content.quests[user.party.quest.key]; + + // if (ranCron) res.locals.wasModified = true; // TODO remove? + if (!ranCron) return next(null, user); // TODO why are we passing user to next? + // TODO Group.tavernBoss(user, progress); + if (!quest || true /* TODO remove */) return user.save(next); + + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + // TODO do + /* async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + });*/ + }); +} diff --git a/website/src/models/user.js b/website/src/models/user.js index 0810bbfa62..a2e414a296 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -45,6 +45,7 @@ export let schema = new Schema({ // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which // have been updated (http://goo.gl/gQLz41), but we want *every* update _v: { type: Number, default: 0 }, + // TODO give all this a default of 0? achievements: { originalUser: Boolean, habitSurveys: Number, @@ -65,7 +66,7 @@ export let schema = new Schema({ quests: Schema.Types.Mixed, // TODO remove, use dictionary? rebirths: Number, rebirthLevel: Number, - perfect: Number, + perfect: {type: Number, default: 0}, habitBirthdays: Number, valentine: Number, costumeContest: Boolean, // Superseded by costumeContests @@ -627,6 +628,11 @@ schema.pre('save', true, function preSaveUser (next, done) { } }); +// TODO unit test this? +schema.methods.isSubscribed = function isSubscribed () { + return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion +}; + schema.methods.unlink = function unlink (options, cb) { let cid = options.cid; let keep = options.keep;