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];