diff --git a/.eslintignore b/.eslintignore index 93a77392f1..a623e23548 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,10 +3,15 @@ common/dist/ common/transpiled-babel/ coverage/ database_reports/ -migrations/ website/build/ website/transpiled-babel/ +migrations/* +!migrations/api_v3 +!migrations/api_v3 +!migrations/api_v3 +migrations/api_v3/old_models + # The files in website/public/js should be moved out and browserified website/public/ diff --git a/migrations/api_v3/users.js b/migrations/api_v3/users.js new file mode 100644 index 0000000000..9d694c3374 --- /dev/null +++ b/migrations/api_v3/users.js @@ -0,0 +1,229 @@ +/* eslint-disable no-console */ + +// Migrate users collection to new schema +// This should run AFTER challenges migration + +// This code makes heavy use of ES6 / 7 features and should be compiled / run with BabelJS. + +// It requires two environment variables: MONGODB_OLD and MONGODB_NEW + +console.log('Starting migrations/api_v3/users.js.'); + +import Q from 'q'; +import MongoDB from 'mongodb'; +import nconf from 'nconf'; +import mongoose from 'mongoose'; +import _ from 'lodash'; + +// Initialize configuration +import setupNconf from '../../website/src/libs/api-v3/setupNconf'; +setupNconf(); + +const MONGODB_OLD = nconf.get('MONGODB_OLD'); +const MONGODB_NEW = nconf.get('MONGODB_NEW'); + +mongoose.Promise = Q.Promise; // otherwise mongoose models won't work + +// Load old and new models +import { model as NewUser } from '../../website/src/models/user'; +import * as Tasks from '../../website/src/models/task'; + +// To be defined later when MongoClient connects +let mongoDbOldInstance; +let oldUserCollection; + +let mongoDbNewInstance; +let newUserCollection; +let newTaskCollection; + +async function processUser (_id) { + let [oldUser] = await oldUserCollection + .find({_id}) + .limit(1) + .toArray(); + + let oldTasks = oldUser.habits.concat(oldUser.dailys).concat(oldUser.rewards).concat(oldUser.todos); + oldUser.habits = oldUser.dailys = oldUser.rewards = oldUser.todos = undefined; + + oldUser.challenges = []; + oldUser.invitations.guilds = []; + oldUser.invitations.party = {}; + oldUser.party = {}; + oldUser.tags = oldUser.tags.map(tag => { + return { + _id: tag.id, + name: tag.name, + challenge: tag.challenge, + }; + }); + + let newUser = new NewUser(oldUser); + + let batchInsertTasks = newTaskCollection.initializeUnorderedBulkOp(); + oldTasks.forEach(oldTask => { + let newTask = new Tasks[oldTask.type](oldTask); + newTask.userId = newUser._id; + + newTask.challenge = {}; + if (!oldTask.text) newTask.text = 'text'; + newTask.tags = _.map(oldTask.tags, (tagPresent, tagId) => { + return tagPresent && tagId; + }); + + newUser.tasksOrder[`${oldTask.type}s`].push(newTask._id); + + // newTask.legacyId = oldTask.id; + + batchInsertTasks.insert(newTask.toObject()); + }); + + await Q.all([ + newUserCollection.insertOne(newUser.toObject()), + batchInsertTasks.execute(), + ]); + + console.log(`Saved user ${newUser._id} and their tasks.`); +} + +/* + +TODO var challengeTasksChangedId = {}; +... given a user + +let processed = 0; +let batchSize = 1000; + +var db; // defined later by MongoClient +var dbNewUsers; +var dbTasks; + +var processUser = function(gt) { + var query = { + _id: {} + }; + if(gt) query._id.$gt = gt; + + console.log('Launching query', query); + + // take batchsize docs from users and process them + OldUserModel + .find(query) + .lean() // Use plain JS objects as old user data won't match the new model + .limit(batchSize) + .sort({_id: 1}) + .exec(function(err, users) { + if(err) throw err; + + console.log('Processing ' + users.length + ' users.', 'Already processed: ' + processed); + + var lastUser = null; + if(users.length === batchSize){ + lastUser = users[users.length - 1]; + } + + var tasksToSave = 0; + + // Initialize batch operation for later + var batchInsertUsers = dbNewUsers.initializeUnorderedBulkOp(); + var batchInsertTasks = dbTasks.initializeUnorderedBulkOp(); + + users.forEach(function(user){ + // user obj is a plain js object because we used .lean() + + // add tasks order arrays + user.tasksOrder = { + habits: [], + rewards: [], + todos: [], + dailys: [] + }; + + // ... convert tasks to individual models + + var tasksArr = user.dailys + .concat(user.habits) + .concat(user.todos) + .concat(user.rewards); + + // free memory? + user.dailys = user.habits = user.todos = user.rewards = undefined; + + tasksArr.forEach(function(task){ + task.userId = user._id; + + task._id = shared.uuid(); // we rely on these to be unique... hopefully! + task.legacyId = task.id; + task.id = undefined; + + task.challenge = task.challenge || {}; + if(task.challenge.id) { + // If challengeTasksChangedId[task._id] then we got on of the duplicates from the challenges migration + if (challengeTasksChangedId[task.legacyId]) { + var res = _.find(challengeTasksChangedId[task.legacyId], function(arr){ + return arr[1] === task.challenge.id; + }); + + // If res, id changed, otherwise matches the original one + task.challenge.taskId = res ? res[0] : task.legacyId; + } else { + task.challenge.taskId = task.legacyId; + } + } + + if(!task.type) console.log('Task without type ', task._id, ' user ', user._id); + + task = new TaskModel(task); // this should also fix dailies that wen to the habits array or vice-versa + user.tasksOrder[task.type + 's'].push(task._id); + tasksToSave++; + batchInsertTasks.insert(task.toObject()); + }); + + batchInsertUsers.insert((new NewUserModel(user)).toObject()); + }); + + console.log('Saving', users.length, 'users and', tasksToSave, 'tasks'); + + // Save in the background and dispatch another processUser(); + + batchInsertUsers.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'users') + }); + + batchInsertTasks.execute(function(err, result){ + if(err) throw err // we can't simply accept errors + console.log('Saved', result.nInserted, 'tasks') + }); + + processed = processed + users.length; + if(lastUser && lastUser._id){ + processUser(lastUser._id); + } else { + console.log('Done!'); + } + }); +}; +*/ + +// Connect to the databases +const MongoClient = MongoDB.MongoClient; + +Q.all([ + MongoClient.connect(MONGODB_OLD), + MongoClient.connect(MONGODB_NEW), +]) +.then(([oldInstance, newInstance]) => { + mongoDbOldInstance = oldInstance; + oldUserCollection = mongoDbOldInstance.collection('users'); + + mongoDbNewInstance = newInstance; + newUserCollection = mongoDbNewInstance.collection('users'); + newTaskCollection = mongoDbNewInstance.collection('tasks'); + + console.log(`Connected with MongoClient to ${MONGODB_OLD} and ${MONGODB_NEW}.`); + + return processUser(nconf.get('USER_ID')); +}) +.catch(err => { + throw err; +}); diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js new file mode 100644 index 0000000000..d4a83634f3 --- /dev/null +++ b/test/api/v3/unit/libs/cron.test.js @@ -0,0 +1,573 @@ +/* eslint-disable global-require */ +import moment from 'moment'; +import { cron } from '../../../../../website/src/libs/api-v3/cron'; +import { model as User } from '../../../../../website/src/models/user'; +import * as Tasks from '../../../../../website/src/models/task'; +import { clone } from 'lodash'; +import common from '../../../../../common'; + +// const scoreTask = common.ops.scoreTask; + +describe('cron', () => { + let user; + let tasksByType = {habits: [], dailys: [], todos: [], rewards: []}; + let daysMissed = 0; + let analytics = { + track: sinon.spy(), + }; + + beforeEach(() => { + user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, + }); + + user._statsComputed = { + mp: 10, + }; + }); + + it('updates user.auth.timestamps.loggedin and lastCron', () => { + let now = new Date(); + + cron({user, tasksByType, daysMissed, analytics, now}); + + expect(user.auth.timestamps.loggedin).to.equal(now); + expect(user.lastCron).to.equal(now); + }); + + it('updates user.preferences.timezoneOffsetAtLastCron', () => { + let timezoneOffsetFromUserPrefs = 1; + + cron({user, tasksByType, daysMissed, analytics, timezoneOffsetFromUserPrefs}); + + expect(user.preferences.timezoneOffsetAtLastCron).to.equal(timezoneOffsetFromUserPrefs); + }); + + it('resets user.items.lastDrop.count', () => { + user.items.lastDrop.count = 4; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.items.lastDrop.count).to.equal(0); + }); + + it('increments user cron count', () => { + let cronCountBefore = user.flags.cronCount; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.flags.cronCount).to.be.greaterThan(cronCountBefore); + }); + + describe('end of the month perks', () => { + beforeEach(() => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + }); + + it('resets plan.gemsBought on a new month', () => { + user.purchased.plan.gemsBought = 10; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.gemsBought).to.equal(0); + }); + + it('resets plan.dateUpdated on a new month', () => { + let currentMonth = moment().format('MMYYYY'); + cron({user, tasksByType, daysMissed, analytics}); + expect(moment(user.purchased.plan.dateUpdated).format('MMYYYY')).to.equal(currentMonth); + }); + + it('increments plan.consecutive.count', () => { + user.purchased.plan.consecutive.count = 0; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.count).to.equal(1); + }); + + it('decrements plan.consecutive.offset when offset is greater than 0', () => { + user.purchased.plan.consecutive.offset = 1; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.offset).to.equal(0); + }); + + it('increments plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.trinkets).to.equal(1); + }); + + it('increments plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(5); + }); + + it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { + user.purchased.plan.consecutive.gemCapExtra = 25; + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); + }); + + it('does not reset plan stats if we are before the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.customerId).to.exist; + }); + + it('does reset plan stats until we are after the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); + user.purchased.plan.consecutive.gemCapExtra = 20; + user.purchased.plan.consecutive.count = 5; + user.purchased.plan.consecutive.offset = 1; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.purchased.plan.customerId).to.not.exist; + expect(user.purchased.plan.consecutive.gemCapExtra).to.be.empty; + expect(user.purchased.plan.consecutive.count).to.be.empty; + expect(user.purchased.plan.consecutive.offset).to.be.empty; + }); + }); + + describe('end of the month perks when user is not subscribed', () => { + it('does not reset plan.gemsBought on a new month', () => { + user.purchased.plan.gemsBought = 10; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.gemsBought).to.equal(10); + }); + + it('does not reset plan.dateUpdated on a new month', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.dateUpdated).to.be.empty; + }); + + it('does not increment plan.consecutive.count', () => { + user.purchased.plan.consecutive.count = 0; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.count).to.equal(0); + }); + + it('does not decrement plan.consecutive.offset when offset is greater than 0', () => { + user.purchased.plan.consecutive.offset = 1; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.offset).to.equal(1); + }); + + it('does not increment plan.consecutive.trinkets when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.trinkets).to.equal(0); + }); + + it('doest not increment plan.consecutive.gemCapExtra when user has reached a month that is a multiple of 3', () => { + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(0); + }); + + it('does not increment plan.consecutive.gemCapExtra when user has reached the gemCap limit', () => { + user.purchased.plan.consecutive.gemCapExtra = 25; + user.purchased.plan.consecutive.count = 5; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.consecutive.gemCapExtra).to.equal(25); + }); + + it('does nothing to plan stats if we are before the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).add({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(user.purchased.plan.customerId).to.not.exist; + }); + + xit('does nothing to plan stats when we are after the last day of the cancelled month', () => { + user.purchased.plan.dateTerminated = moment(new Date()).subtract({days: 1}); + user.purchased.plan.consecutive.gemCapExtra = 20; + user.purchased.plan.consecutive.count = 5; + user.purchased.plan.consecutive.offset = 1; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.purchased.plan.customerId).to.exist; + expect(user.purchased.plan.consecutive.gemCapExtra).to.exist; + expect(user.purchased.plan.consecutive.count).to.exist; + expect(user.purchased.plan.consecutive.offset).to.exist; + }); + }); + + describe('user is sleeping', () => { + beforeEach(() => { + user.preferences.sleep = true; + }); + + it('clears user buffs', () => { + user.stats.buffs = { + str: 1, + int: 1, + per: 1, + con: 1, + stealth: 1, + streaks: true, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.equal(0); + expect(user.stats.buffs.int).to.equal(0); + expect(user.stats.buffs.per).to.equal(0); + expect(user.stats.buffs.con).to.equal(0); + expect(user.stats.buffs.stealth).to.equal(0); + expect(user.stats.buffs.streaks).to.be.false; + }); + + it('resets all dailies without damaging user', () => { + let daily = { + text: 'test daily', + type: 'daily', + frequency: 'daily', + everyX: 5, + startDate: new Date(), + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys.push(task); + tasksByType.dailys[0].completed = true; + + let healthBefore = user.stats.hp; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.dailys[0].completed).to.be.false; + expect(user.stats.hp).to.equal(healthBefore); + }); + }); + + describe('todos', () => { + beforeEach(() => { + let todo = { + text: 'test todo', + type: 'todo', + value: 0, + }; + + let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap + tasksByType.todos.push(task); + }); + + it('should make uncompleted todos redder', () => { + let valueBefore = tasksByType.todos[0].value; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.todos[0].value).to.be.lessThan(valueBefore); + }); + + it('should add history of completed todos to user history', () => { + tasksByType.todos[0].completed = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.history.todos).to.be.lengthOf(1); + }); + }); + + describe('dailys', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + }); + + it('should add history', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].history).to.be.lengthOf(1); + }); + + it('should set tasks completed to false', () => { + tasksByType.dailys[0].completed = true; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].completed).to.be.false; + }); + + it('should set task checklist to completed for completed dailys', () => { + tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); + tasksByType.dailys[0].completed = true; + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + }); + + it('should set task checklist to completed for dailys with scheduled misses', () => { + daysMissed = 10; + tasksByType.dailys[0].checklist.push({title: 'test', completed: false}); + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + expect(tasksByType.dailys[0].checklist[0].completed).to.be.true; + }); + + it('should do damage for missing a daily', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.hp).to.be.lessThan(hpBefore); + }); + + it('should not do damage for missing a daily if user stealth buff is greater than or equal to days missed', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + user.stats.buffs.stealth = 2; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.hp).to.equal(hpBefore); + }); + + it('should do less damage for missing a daily with partial completion', () => { + daysMissed = 1; + let hpBefore = user.stats.hp; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + cron({user, tasksByType, daysMissed, analytics}); + let hpDifferenceOfFullyIncompleteDaily = hpBefore - user.stats.hp; + + hpBefore = user.stats.hp; + tasksByType.dailys[0].checklist.push({title: 'test', completed: true}); + tasksByType.dailys[0].checklist.push({title: 'test2', completed: false}); + cron({user, tasksByType, daysMissed, analytics}); + let hpDifferenceOfPartiallyIncompleteDaily = hpBefore - user.stats.hp; + + expect(hpDifferenceOfPartiallyIncompleteDaily).to.be.lessThan(hpDifferenceOfFullyIncompleteDaily); + }); + + it('should decrement quest progress down for missing a daily', () => { + daysMissed = 1; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + let progress = cron({user, tasksByType, daysMissed, analytics}); + + expect(progress.down).to.equal(-1); + }); + }); + + describe('habits', () => { + beforeEach(() => { + let habit = { + text: 'test habit', + type: 'habit', + }; + + let task = new Tasks.habit(Tasks.Task.sanitize(habit)); // eslint-disable-line babel/new-cap + tasksByType.habits = []; + tasksByType.habits.push(task); + }); + + it('should decrement only up value', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].down = false; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.be.lessThan(1); + }); + + it('should decrement only down value', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].up = false; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.be.lessThan(1); + }); + + it('should do nothing to habits with both up and down', () => { + tasksByType.habits[0].value = 1; + tasksByType.habits[0].up = true; + tasksByType.habits[0].down = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].value).to.equal(1); + }); + }); + + describe('perfect day', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + }); + + it('stores a new entry in user.history.exp', () => { + user.stats.lvl = 2; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.history.exp).to.have.lengthOf(1); + expect(user.history.exp[0].value).to.equal(150); + }); + + it('increments perfect day achievement', () => { + tasksByType.dailys[0].completed = true; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.achievements.perfect).to.equal(1); + }); + + it('increments user buffs if they have a perfect day', () => { + tasksByType.dailys[0].completed = true; + + let previousBuffs = clone(user.stats.buffs); + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.be.greaterThan(previousBuffs.str); + expect(user.stats.buffs.int).to.be.greaterThan(previousBuffs.int); + expect(user.stats.buffs.per).to.be.greaterThan(previousBuffs.per); + expect(user.stats.buffs.con).to.be.greaterThan(previousBuffs.con); + }); + + it('clears buffs if user does not have a perfect day', () => { + daysMissed = 1; + tasksByType.dailys[0].completed = false; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + + user.stats.buffs = { + str: 1, + int: 1, + per: 1, + con: 1, + stealth: 0, + streaks: true, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.stats.buffs.str).to.equal(0); + expect(user.stats.buffs.int).to.equal(0); + expect(user.stats.buffs.per).to.equal(0); + expect(user.stats.buffs.con).to.equal(0); + expect(user.stats.buffs.stealth).to.equal(0); + expect(user.stats.buffs.streaks).to.be.false; + }); + }); + + describe('adding mp', () => { + it('should add mp to user', () => { + let mpBefore = user.stats.mp; + tasksByType.dailys[0].completed = true; + user._statsComputed.maxMP = 100; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.stats.mp).to.be.greaterThan(mpBefore); + }); + + it('set user\'s mp to user._statsComputed.maxMP when user.stats.mp is greater', () => { + user.stats.mp = 120; + user._statsComputed.maxMP = 100; + cron({user, tasksByType, daysMissed, analytics}); + expect(user.stats.mp).to.equal(user._statsComputed.maxMP); + }); + }); + + describe('quest progress', () => { + beforeEach(() => { + let daily = { + text: 'test daily', + type: 'daily', + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + tasksByType.dailys = []; + tasksByType.dailys.push(task); + + user._statsComputed = { + con: 1, + }; + + daysMissed = 1; + tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1}); + }); + + it('resets user progress', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.party.quest.progress.up).to.equal(0); + expect(user.party.quest.progress.down).to.equal(0); + expect(user.party.quest.progress.collect).to.be.empty; + }); + + it('applies the user progress', () => { + let progress = cron({user, tasksByType, daysMissed, analytics}); + expect(progress.down).to.equal(-1); + }); + }); + + describe('private messages', () => { + let lastMessageId; + + beforeEach(() => { + let maxPMs = 200; + for (let index = 0; index < maxPMs - 1; index += 1) { + let messageId = common.uuid(); + user.inbox.messages[messageId] = { + id: messageId, + text: `test ${index}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + } + + lastMessageId = common.uuid(); + user.inbox.messages[lastMessageId] = { + id: lastMessageId, + text: `test ${lastMessageId}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + }); + + xit('does not clear pms under 200', () => { + cron({user, tasksByType, daysMissed, analytics}); + expect(user.inbox.messages[lastMessageId]).to.exist; + }); + + xit('clears pms over 200', () => { + let messageId = common.uuid(); + user.inbox.messages[messageId] = { + id: messageId, + text: `test ${messageId}`, + timestamp: Number(new Date()), + likes: {}, + flags: {}, + flagCount: 0, + }; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(user.inbox.messages[messageId]).to.not.exist; + }); + }); +}); diff --git a/test/api/v3/unit/middlewares/cronMiddleware.js b/test/api/v3/unit/middlewares/cronMiddleware.js new file mode 100644 index 0000000000..196de33dad --- /dev/null +++ b/test/api/v3/unit/middlewares/cronMiddleware.js @@ -0,0 +1,176 @@ +import { + generateRes, + generateReq, + generateNext, + generateTodo, + generateDaily, +} from '../../../../helpers/api-unit.helper'; +import cronMiddleware from '../../../../../website/src/middlewares/api-v3/cron'; +import moment from 'moment'; +import { model as User } from '../../../../../website/src/models/user'; +import { model as Group } from '../../../../../website/src/models/group'; +import * as Tasks from '../../../../../website/src/models/task'; +import analyticsService from '../../../../../website/src/libs/api-v3/analyticsService'; +import { v4 as generateUUID } from 'uuid'; + +describe('cron middleware', () => { + let res, req, next; + let user; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, + }, + }); + + user._statsComputed = { + mp: 10, + maxMP: 100, + }; + + res.locals.user = user; + res.analytics = analyticsService; + }); + + it('calls next when user is not attached', () => { + res.locals.user = null; + cronMiddleware(req, res, next); + expect(next).to.be.calledOnce; + }); + + it('calls next when days have not been missed', () => { + cronMiddleware(req, res, next); + expect(next).to.be.calledOnce; + }); + + it('should clear todos older than 30 days for free users', async (done) => { + user.lastCron = moment(new Date()).subtract({days: 2}); + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 31}); + task.completed = true; + await task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.not.exist; + done(); + }); + }); + }); + + it('should not clear todos older than 30 days for subscribed users', (done) => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({days: 2}); + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 31}); + task.completed = true; + task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.exist; + done(); + }); + }); + }); + + it('should clear todos older than 90 days for subscribed users', (done) => { + user.purchased.plan.customerId = 'subscribedId'; + user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY'); + user.lastCron = moment(new Date()).subtract({days: 2}); + + let task = generateTodo(user); + task.dateCompleted = moment(new Date()).subtract({days: 91}); + task.completed = true; + task.save(); + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: task}, function (err, taskFound) { + expect(err).to.not.exist; + expect(taskFound).to.not.exist; + done(); + }); + }); + }); + + it('should call next is user was not modified after cron', (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + generateDaily(user); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.equal(hpBefore); + done(); + }); + }); + + it('does damage for missing dailies', (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + let daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({days: 2}); + daily.save(); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.lessThan(hpBefore); + done(); + }); + }); + + it('updates tasks', (done) => { + user.lastCron = moment(new Date()).subtract({days: 2}); + let todo = generateTodo(user); + let todoValueBefore = todo.value; + + cronMiddleware(req, res, () => { + Tasks.Task.findOne({_id: todo._id}, function (err, todoFound) { + expect(err).to.not.exist; + expect(todoFound.value).to.be.lessThan(todoValueBefore); + done(); + }); + }); + }); + + it('applies quest progress', async (done) => { + let hpBefore = user.stats.hp; + user.lastCron = moment(new Date()).subtract({days: 2}); + let daily = generateDaily(user); + daily.startDate = moment(new Date()).subtract({days: 2}); + daily.save(); + + let questKey = 'dilatory'; + user.party.quest.key = questKey; + + let party = new Group({ + type: 'party', + name: generateUUID(), + leader: user._id, + }); + party.quest.members[user._id] = true; + party.quest.key = questKey; + await party.save(); + + user.party._id = party._id; + await user.save(); + + party.startQuest(user); + + cronMiddleware(req, res, () => { + expect(user.stats.hp).to.be.lessThan(hpBefore); + done(); + }); + }); +}); diff --git a/test/helpers/api-unit.helper.js b/test/helpers/api-unit.helper.js index 489e0dd22e..beeac66098 100644 --- a/test/helpers/api-unit.helper.js +++ b/test/helpers/api-unit.helper.js @@ -6,6 +6,7 @@ import { model as Group } from '../../website/src/models/group'; import mongo from './mongo'; // eslint-disable-line import moment from 'moment'; import i18n from '../../common/script/i18n'; +import * as Tasks from '../../website/src/models/task'; afterEach((done) => { sandbox.restore(); @@ -70,3 +71,33 @@ export function generateHistory (days) { return history; } + +export function generateTodo (user) { + let todo = { + text: 'test todo', + type: 'todo', + value: 0, + completed: false, + }; + + let task = new Tasks.todo(Tasks.Task.sanitize(todo)); // eslint-disable-line babel/new-cap + task.userId = user._id; + task.save(); + + return task; +} + +export function generateDaily (user) { + let daily = { + text: 'test daily', + type: 'daily', + value: 0, + completed: false, + }; + + let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line babel/new-cap + task.userId = user._id; + task.save(); + + return task; +} diff --git a/website/src/libs/api-v3/cron.js b/website/src/libs/api-v3/cron.js new file mode 100644 index 0000000000..61a2b46c72 --- /dev/null +++ b/website/src/libs/api-v3/cron.js @@ -0,0 +1,273 @@ +import moment from 'moment'; +import common from '../../../../common/'; +import { preenUserHistory } from '../../libs/api-v3/preening'; +import _ from 'lodash'; + +const shouldDo = common.shouldDo; +const scoreTask = common.ops.scoreTask; +// const maxPMs = 200; + +let CLEAR_BUFFS = { + str: 0, + int: 0, + per: 0, + con: 0, + stealth: 0, + streaks: false, +}; + +function grantEndOfTheMonthPerks (user, now) { + let plan = user.purchased.plan; + + 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'); + } +} + +function performSleepTasks (user, tasksByType, now) { + user.stats.buffs = _.cloneDeep(CLEAR_BUFFS); + + 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; + }); +} + +// 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 function cron (options = {}) { + let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; + + user.auth.timestamps.loggedin = now; + user.lastCron = now; + user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + // 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; + + if (user.isSubscribed()) { + grantEndOfTheMonthPerks(user, now); + } + + // User is resting at the inn. + // On cron, buffs are cleared and all dailies are reset without performing damage + if (user.preferences.sleep === true) { + performSleepTasks(user, tasksByType, now); + 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 + scoreTask({ + task, + user, + direction: 'down', + cron: true, + times: multiDaysCountAsOneDay ? 1 : daysMissed, + }); + + todoTally += task.value; + }); + + 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.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}); + + // 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. + preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); + + 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(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 + 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); + + // @TODO: Clean PMs - keep 200 for subscribers and 50 for free users + // let numberOfPMs = Object.keys(user.inbox.messages).length; + // if (numberOfPMs > maxPMs) { + // _(user.inbox.messages) + // .sortBy('timestamp') + // .takeRight(numberOfPMs - maxPMs) + // .each(pm => { + // delete user.inbox.messages[pm.id]; + // }).value(); + // + // user.markModified('inbox.messages'); + // } + + // 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; +} diff --git a/website/src/middlewares/api-v3/cron.js b/website/src/middlewares/api-v3/cron.js index 2711712ed4..50c6e32548 100644 --- a/website/src/middlewares/api-v3/cron.js +++ b/website/src/middlewares/api-v3/cron.js @@ -5,274 +5,13 @@ import * as Tasks from '../../models/task'; import Q from 'q'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; -import { preenUserHistory } from '../../libs/api-v3/preening'; +import { cron } from '../../libs/api-v3/cron'; const daysSince = common.daysSince; -const shouldDo = common.shouldDo; - -const scoreTask = common.ops.scoreTask; - -let clearBuffs = { - str: 0, - int: 0, - per: 0, - con: 0, - stealth: 0, - streaks: false, -}; - -// 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. -function cron (options = {}) { - let {user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs} = options; - - user.auth.timestamps.loggedin = now; - user.lastCron = now; - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; - // 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; - - // 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}); // TODO 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'); - } - } - - // 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 - scoreTask({ - task, - user, - direction: 'down', - cron: true, - times: multiDaysCountAsOneDay ? 1 : daysMissed, - }); - - todoTally += task.value; - }); - - 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); // TODO 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.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}); - - // 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. - preenUserHistory(user, tasksByType, user.preferences.timezoneOffset); - - 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); - - progress.down = 0; - progress.up = 0; - - progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0); - - // Clean PMs - keep 200 for subscribers and 50 for free users - // TODO tests - let maxPMs = user.isSubscribed() ? 200 : 50; // TODO 200 limit for contributors too - let numberOfPMs = Object.keys(user.inbox.messages).length; - if (Object.keys(user.inbox.messages).length > maxPMs) { - _(user.inbox.messages) - .sortBy('timestamp') - .takeRight(numberOfPMs - maxPMs) - .each(pm => { - user.inbox.messages[pm.id] = undefined; - }).value(); - - user.markModified('inbox.messages'); - } - - // Analytics - user.flags.cronCount++; - 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, - }); - - return _progress; -} module.exports = function cronMiddleware (req, res, next) { let user = res.locals.user; + if (!user) return next(); // User might not be available when authentication is not mandatory let analytics = res.analytics; @@ -381,7 +120,7 @@ module.exports = function cronMiddleware (req, res, next) { type: 'todo', completed: true, dateCompleted: { - $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days'), + $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), }, 'challenge.id': {$exists: false}, }).exec(); // TODO wait before returning? @@ -397,13 +136,13 @@ module.exports = function cronMiddleware (req, res, next) { // Save user and tasks let toSave = [user.save()]; tasks.forEach(task => { - if (task.isModified) toSave.push(task.save()); + toSave.push(task.save()); }); + Q.all(toSave) .then(saved => { user = res.locals.user = saved[0]; if (!quest) return; - // If user is on a quest, roll for boss & player, or handle collections let questType = quest.boss ? 'boss' : 'collect'; // TODO this saves user, runs db updates, loads user. Is there a better way to handle this? diff --git a/website/src/models/group.js b/website/src/models/group.js index 9542500d91..6f2b2a0b14 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -475,7 +475,6 @@ function _isOnQuest (user, progress, group) { // Returns a promise schema.statics.collectQuest = async function collectQuest (user, progress) { let group = await this.getGroup({user, groupId: 'party'}); - if (!_isOnQuest(user, progress, group)) return; let quest = shared.content.quests[group.quest.key]; diff --git a/website/views/shared/new-stuff.jade b/website/views/shared/new-stuff.jade index 5ac9bb44b3..4fe5b0f8f2 100644 --- a/website/views/shared/new-stuff.jade +++ b/website/views/shared/new-stuff.jade @@ -1,42 +1,49 @@ -h2 4/15/2016 - iOS AND ANDROID UPDATES, AND ANOTHER BEGUILEMENT STRIKE! +h2 4/23/2016 - THE BE-WILDER STRIKES AGAIN! hr tr td - h3 iOS Update: Fixes Galore - p We've released a new iOS update focusing on stability and bug fixes! + h3 World Boss: Third Beguilement Strike! + p Look out! In the middle of reporting the news, Bailey the Town Crier has been possessed by the Be-Wilder! She lets out an evil, uninformative screech as she rises into the air. Now how will we know what’s going on? br - p It includes multiple crash fixes, most notably for iOS 7 and for quests with rage bars, and clears up frustrating bugs such as the issues with timezones, the duplicating items bug, and the fact that completed to-dos used to still cause reminders. Be sure to download it now for a better Habitica experience! - br - p Thank you very much for your patience! If you like the improvements that we’ve been making to our app, please consider reviewing this new version. It really helps us out! Also, old reviews get hidden, but if you go to the review section you can re-post it again with a single tap. We hope you enjoy the update! - p.small.muted by viirus, schrockblock, a-ayyash, and nivl4 - tr - td - .Pet-Wolf-Base.pull-left.slight-right-margin - h3 Android App Update: Pets, Enchanted Armoire, and More! - p In case you missed it, yesterday we updated the Android app to include a ton of features, including: - br - ul - li Pets and Mounts! Now you can use the app to hatch pets, feed them, and equip pets and mounts. - li The World Boss! Now you can view the World Boss Battle from the Tavern on the app! - li The Enchanted Armoire! Tap right from the app for a chance at equipment, Experience, or food. - li Edit Task Attributes! Assign attributes to tasks for use with task-based point allocation. - li Crash and Bug Fixes! Including fixes for incorrectly-locked backgrounds, date display, editing task attribution allocation, and more. - br - p If you like what we're doing with the app, please consider leaving a review! It means a lot to us. - p.small.muted by viirus, saranlert, schrockblock, ablx and jeubank12 - tr - td - .npc_matt_broken.pull-right - h3 World Boss: Second Beguilement Strike! - p In more frightening news, the Be-Wilder has used another Beguilement Strike! - br - p Once again the Be-Wilder has dazzled us into neglecting our Dailies, and now it has attacked Matt the Beast Master! With a swirl of mist, Matt transforms into a terrifying winged creature, and all the pets and mounts howl sadly in their stables. Quickly, stay focused on your tasks to defeat this dastardly distraction! + p Don't give up... we're so close to defeating this bothersome bird for once and for all! if menuItem !== 'oldNews' hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h2 4/15/2016 - iOS AND ANDROID UPDATES, AND ANOTHER BEGUILEMENT STRIKE! + tr + td + h3 iOS Update: Fixes Galore + p We've released a new iOS update focusing on stability and bug fixes! + br + p It includes multiple crash fixes, most notably for iOS 7 and for quests with rage bars, and clears up frustrating bugs such as the issues with timezones, the duplicating items bug, and the fact that completed to-dos used to still cause reminders. Be sure to download it now for a better Habitica experience! + br + p Thank you very much for your patience! If you like the improvements that we’ve been making to our app, please consider reviewing this new version. It really helps us out! Also, old reviews get hidden, but if you go to the review section you can re-post it again with a single tap. We hope you enjoy the update! + p.small.muted by viirus, schrockblock, a-ayyash, and nivl4 + tr + td + .Pet-Wolf-Base.pull-left.slight-right-margin + h3 Android App Update: Pets, Enchanted Armoire, and More! + p In case you missed it, yesterday we updated the Android app to include a ton of features, including: + br + ul + li Pets and Mounts! Now you can use the app to hatch pets, feed them, and equip pets and mounts. + li The World Boss! Now you can view the World Boss Battle from the Tavern on the app! + li The Enchanted Armoire! Tap right from the app for a chance at equipment, Experience, or food. + li Edit Task Attributes! Assign attributes to tasks for use with task-based point allocation. + li Crash and Bug Fixes! Including fixes for incorrectly-locked backgrounds, date display, editing task attribution allocation, and more. + br + p If you like what we're doing with the app, please consider leaving a review! It means a lot to us. + p.small.muted by viirus, saranlert, schrockblock, ablx and jeubank12 + tr + td + .npc_matt_broken.pull-right + h3 World Boss: Second Beguilement Strike! + p In more frightening news, the Be-Wilder has used another Beguilement Strike! + br + p Once again the Be-Wilder has dazzled us into neglecting our Dailies, and now it has attacked Matt the Beast Master! With a swirl of mist, Matt transforms into a terrifying winged creature, and all the pets and mounts howl sadly in their stables. Quickly, stay focused on your tasks to defeat this dastardly distraction! h2 4/14/2016 - ANDROID APP UPDATES! tr td