diff --git a/common/script/fns/randomDrop.js b/common/script/fns/randomDrop.js index b0121b157b..0ab6a5e69a 100644 --- a/common/script/fns/randomDrop.js +++ b/common/script/fns/randomDrop.js @@ -11,7 +11,7 @@ function cloneDropItem (drop) { }); } -module.exports = function(user, modifiers, req) { +module.exports = function randomDrop (user, modifiers, req) { var acceptableDrops, base, base1, base2, chance, drop, dropK, dropMultiplier, name, name1, name2, quest, rarity, ref, ref1, ref2, ref3, task; task = modifiers.task; chance = _.min([Math.abs(task.value - 21.27), 37.5]) / 150 + .02; diff --git a/common/script/fns/updateStats.js b/common/script/fns/updateStats.js index 3ce83ed664..51e78ccb79 100644 --- a/common/script/fns/updateStats.js +++ b/common/script/fns/updateStats.js @@ -4,7 +4,8 @@ import { MAX_STAT_POINTS } from '../constants'; import { toNextLevel } from '../statHelpers'; -module.exports = function (user, stats, req, analytics) { + +module.exports = function updateStats (user, stats, req, analytics) { let allocatedStatPoints; let totalStatPoints; let experienceToNextLevel; diff --git a/common/script/ops/scoreTask.js b/common/script/ops/scoreTask.js index 5bb8406fd8..f5ff5f115b 100644 --- a/common/script/ops/scoreTask.js +++ b/common/script/ops/scoreTask.js @@ -27,7 +27,7 @@ function _calculateDelta (task, direction, cron) { // Checklists if (task.checklist && task.checklist.length > 0) { - // If the Daily, only dock them them a portion based on their checklist completion + // If the Daily, only dock them a portion based on their checklist completion if (direction === 'down' && task.type === 'daily' && cron) { nextDelta *= 1 - _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0) / task.checklist.length; } @@ -43,7 +43,7 @@ function _calculateDelta (task, direction, cron) { // Approximates the reverse delta for the task value // This is meant to return the task value to its original value when unchecking a task. -// First, calculate the the value using the normal way for our first guess although +// First, calculate the value using the normal way for our first guess although // it will be a bit off function _calculateReverseDelta (task, direction) { let currVal = _getTaskValue(task.value); @@ -185,6 +185,7 @@ module.exports = function scoreTask (options = {}, req = {}) { // ===== starting to actually do stuff, most of above was definitions ===== if (task.type === 'habit') { delta += _changeTaskValue(user, task, direction, times, cron); + // Add habit value to habit-history (if different) if (delta > 0) { _addPoints(user, task, stats, direction, delta); @@ -213,17 +214,25 @@ module.exports = function scoreTask (options = {}, req = {}) { task.streak += 1; // Give a streak achievement when the streak is a multiple of 21 if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1; - } else { + task.completed = true; + } else if (direction === 'down') { // Remove a streak achievement if streak was a multiple of 21 and the daily was undone if (task.streak % 21 === 0) user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0; task.streak -= 1; + task.completed = false; } } } else if (task.type === 'todo') { if (cron) { // don't touch stats on cron delta += _changeTaskValue(user, task, direction, times, cron); } else { - task.dateCompleted = direction === 'up' ? new Date() : undefined; + if (direction === 'up') { + task.dateCompleted = new Date(); + task.completed = true; + } else if (direction === 'down') { + task.completed = false; + task.dateCompleted = undefined; + } delta += _changeTaskValue(user, task, direction, times, cron); if (direction === 'down') delta = _calculateDelta(task, direction, delta); // recalculate delta for unchecking so the gp and exp come out correctly diff --git a/test/common/ops/scoreTask.test.js b/test/common/ops/scoreTask.test.js new file mode 100644 index 0000000000..727b316d62 --- /dev/null +++ b/test/common/ops/scoreTask.test.js @@ -0,0 +1,203 @@ +import scoreTask from '../../../common/script/ops/scoreTask'; +import { + generateUser, + generateDaily, + generateHabit, + generateTodo, + generateReward, +} from '../../helpers/common.helper'; +import common from '../../../common'; +import i18n from '../../../common/script/i18n'; +import { + NotAuthorized, +} from '../../../common/script/libs/errors'; + +let EPSILON = 0.0001; // negligible distance between datapoints + +/* Helper Functions */ +let rewrapUser = (user) => { + user._wrapped = false; + common.wrap(user); + return user; +}; + +let beforeAfter = () => { + let beforeUser = generateUser(); + let afterUser = _.cloneDeep(beforeUser); + rewrapUser(afterUser); + + return { + beforeUser, + afterUser, + }; +}; + +let expectGainedPoints = (beforeUser, afterUser, beforeTask, afterTask) => { + expect(afterUser.stats.hp).to.eql(50); + expect(afterUser.stats.exp).to.be.greaterThan(beforeUser.stats.exp); + expect(afterUser.stats.gp).to.be.greaterThan(beforeUser.stats.gp); + expect(afterTask.value).to.be.greaterThan(beforeTask.value); + if (afterTask.type === 'habit') { + expect(afterTask.history).to.have.length(1); + } +}; + +let expectClosePoints = (beforeUser, afterUser, beforeTask, task) => { + expect(Math.abs(afterUser.stats.exp - beforeUser.stats.exp)).to.be.lessThan(EPSILON); + expect(Math.abs(afterUser.stats.gp - beforeUser.stats.gp)).to.be.lessThan(EPSILON); + expect(Math.abs(task.value - beforeTask.value)).to.be.lessThan(EPSILON); +}; + +let _expectRoughlyEqualDates = (date1, date2) => { + expect(date1.toString()).to.eql(date2.toString()); +}; + +describe('shared.ops.scoreTask', () => { + let ref; + + beforeEach(() => { + ref = beforeAfter(); + }); + + it('throws an error when scoring a reward if user does not have enough gold', (done) => { + let reward = generateReward({ userId: ref.afterUser._id, text: 'some reward', value: 100 }); + try { + scoreTask({ user: ref.afterUser, task: reward }); + } catch (err) { + expect(err).to.be.an.instanceof(NotAuthorized); + expect(err.message).to.eql(i18n.t('messageNotEnoughGold')); + done(); + } + }); + + it('checks that the streak parameters affects the score', () => { + let task = generateDaily({ userId: ref.afterUser._id, text: 'task to check streak' }); + scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'up', cron: false }); + expect(task.streak).to.eql(2); + }); + + it('completes when the task direction is up', () => { + let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'up' }); + expect(task.completed).to.eql(true); + _expectRoughlyEqualDates(task.dateCompleted, new Date()); + }); + + it('uncompletes when the task direction is down', () => { + let task = generateTodo({ userId: ref.afterUser._id, text: 'todo to complete', cron: false }); + scoreTask({ user: ref.afterUser, task, direction: 'down' }); + expect(task.completed).to.eql(false); + expect(task.dateCompleted).to.not.exist; + }); + + describe('verifies that times parameter in scoring works', () => { + let habit; + + beforeEach(() => { + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + }); + + it('works', () => { + let delta1, delta2, delta3; + + delta1 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); + + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + + delta2 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 4, cron: false }); + + ref = beforeAfter(); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + + delta3 = scoreTask({ user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }); + + expect(Math.abs(delta1 - delta2)).to.be.greaterThan(EPSILON); + expect(Math.abs(delta1 - delta3)).to.be.lessThan(EPSILON); + }); + }); + + describe('scores', () => { + let options = {}; + let habit; + let freshDaily, daily; + let freshTodo, todo; + + beforeEach(() => { + ref = beforeAfter(options); + habit = generateHabit({ userId: ref.afterUser._id, text: 'some habit' }); + freshDaily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); + daily = generateDaily({ userId: ref.afterUser._id, text: 'some daily' }); + freshTodo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); + todo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); + + expect(habit.history.length).to.eql(0); + + // before and after are the same user + expect(ref.beforeUser._id).to.exist; + expect(ref.beforeUser._id).to.eql(ref.afterUser._id); + }); + + context('habits', () => { + it('up', () => { + options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }; + scoreTask(options); + + expect(habit.history.length).to.eql(1); + expect(habit.value).to.be.greaterThan(0); + + expect(ref.afterUser.stats.hp).to.eql(50); + expect(ref.afterUser.stats.exp).to.be.greaterThan(ref.beforeUser.stats.exp); + expect(ref.afterUser.stats.gp).to.be.greaterThan(ref.beforeUser.stats.gp); + }); + + it('down', () => { + scoreTask({user: ref.afterUser, task: habit, direction: 'down', times: 5, cron: false}, {}); + + expect(habit.history.length).to.eql(1); + expect(habit.value).to.be.lessThan(0); + + expect(ref.afterUser.stats.hp).to.be.lessThan(ref.beforeUser.stats.hp); + expect(ref.afterUser.stats.exp).to.eql(0); + expect(ref.afterUser.stats.gp).to.eql(0); + }); + }); + + context('dailys', () => { + it('up', () => { + expect(daily.completed).to.not.eql(true); + scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); + expectGainedPoints(ref.beforeUser, ref.afterUser, freshDaily, daily); + expect(daily.completed).to.eql(true); + }); + + it('up, down', () => { + scoreTask({user: ref.afterUser, task: daily, direction: 'up'}); + scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); + expectClosePoints(ref.beforeUser, ref.afterUser, freshDaily, daily); + }); + + it('sets completed = false on direction = down', () => { + daily.completed = true; + expect(daily.completed).to.not.eql(false); + scoreTask({user: ref.afterUser, task: daily, direction: 'down'}); + expect(daily.completed).to.eql(false); + }); + }); + + context('todos', () => { + it('up', () => { + scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); + expectGainedPoints(ref.beforeUser, ref.afterUser, freshTodo, todo); + }); + + it('up, down', () => { + scoreTask({user: ref.afterUser, task: todo, direction: 'up'}); + scoreTask({user: ref.afterUser, task: todo, direction: 'down'}); + expectClosePoints(ref.beforeUser, ref.afterUser, freshTodo, todo); + }); + }); + }); +}); diff --git a/test/helpers/api-integration/v3/object-generators.js b/test/helpers/api-integration/v3/object-generators.js index 42de6d243c..0d9921159b 100644 --- a/test/helpers/api-integration/v3/object-generators.js +++ b/test/helpers/api-integration/v3/object-generators.js @@ -54,6 +54,13 @@ export async function generateReward (update = {}) { return task; } +export async function generateTodo (update = {}) { + let type = 'todo'; + let task = new Tasks[type](update); + await task.save({ validateBeforeSave: false }); + return task; +} + // Generates a new group. Requires a user object, which // will will become the groups leader. Takes a details argument // for the initial group creation and an update argument which diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f8883312b0..e2adcffedb 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -393,9 +393,6 @@ api.scoreTask = { if (!task) throw new NotFound(res.t('taskNotFound')); let wasCompleted = task.completed; - if (task.type === 'daily' || task.type === 'todo') { - task.completed = direction === 'up'; // TODO move into scoreTask - } let delta = common.ops.scoreTask({task, user, direction}, req); // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)