diff --git a/common/script/api-v3/scoreTask.js b/common/script/api-v3/scoreTask.js new file mode 100644 index 0000000000..2a84f00568 --- /dev/null +++ b/common/script/api-v3/scoreTask.js @@ -0,0 +1,259 @@ +import _ from 'lodash'; +import moment from 'moment'; +import { + NotAuthorized, +} from '../../../website/src/libs/api-v3/errors'; +import i18n from '../i18n'; + +const MAX_TASK_VALUE = 21.27; +const MIN_TASK_VALUE = -47.27; +const CLOSE_ENOUGH = 0.00001; + +function _getTaskValue (taskValue) { + if (taskValue < MIN_TASK_VALUE) { + return MIN_TASK_VALUE; + } else if (taskValue < MAX_TASK_VALUE) { + return MAX_TASK_VALUE; + } else { + return taskValue; + } +} + +// Calculates the next task.value based on direction +// Uses a capped inverse log y=.95^x, y>= -5 +function _calculateDelta (task, direction, cron) { + // Min/max on task redness + let currVal = _getTaskValue(task.value); + let nextDelta = Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Checklists + if (task.checklist && task.checklist.length > 0) { + // If the Daily, only dock them 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; + } + + // If To-Do, point-match the TD per checklist item completed + if (task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + } + + return nextDelta; +} + +// 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 +// it will be a bit off +function _calculateReverseDelta (task, direction) { + let currVal = _getTaskValue(task.value); + let testVal = currVal + Math.pow(0.9747, currVal) * (direction === 'down' ? -1 : 1); + + // Now keep moving closer to the original value until we get "close enough" + // Check how close we are to the original value by computing the delta off our guess + // and looking at the difference between that and our current value. + while (true) { // eslint-disable-line no-constant-condition + let calc = testVal + Math.pow(0.9747, testVal); + let diff = currVal - calc; + + if (Math.abs(diff) < CLOSE_ENOUGH) break; + + if (diff > 0) { + testVal -= diff; + } else { + testVal += diff; + } + } + + // When we get close enough, return the difference between our approximated value + // and the current value. This will be the delta calculated from the original value + // before the task was checked. + let nextDelta = testVal - currVal; + + // Checklists - If To-Do, point-match the TD per checklist item completed + if (task.checklist && task.checklist.length > 0 && task.type === 'todo') { + nextDelta *= 1 + _.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 0); + } + + return nextDelta; +} + +function _gainMP (user, val) { + val *= user._tmp.crit || 1; + user.stats.mp += val; + + if (user.stats.mp >= user._statsComputed.maxMP) user.stats.mp = user._statsComputed.maxMP; + if (user.stats.mp < 0) { + user.stats.mp = 0; + return user.stats.mp; + } +} + +// HP modifier +// ===== CONSTITUTION ===== +// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point. +function _subtractPoints (user, task, stats, delta) { + let conBonus = 1 - user._statsComputed.con / 250; + if (conBonus < 0.1) conBonus = 0.1; + + let hpMod = delta * conBonus * task.priority * 2; // constant 2 multiplier for better results + stats.hp += Math.round(hpMod * 10) / 10; // round to 1dp + return stats.hp; +} + +function _addPoints (user, task, stats, direction, delta) { + // ===== CRITICAL HITS ===== + // allow critical hit only when checking off a task, not when unchecking it: + let _crit = delta > 0 ? user.fns.crit() : 1; + // if there was a crit, alert the user via notification + if (_crit > 1) user._tmp.crit = _crit; + + // Exp Modifier + // ===== Intelligence ===== + // TODO Increases Experience gain by .2% per point. + let intBonus = 1 + user._statsComputed.int * 0.025; + stats.exp += Math.round(delta * intBonus * task.priority * _crit * 6); + + // GP modifier + // ===== PERCEPTION ===== + // TODO Increases Gold gained from tasks by .3% per point. + let perBonus = 1 + user._statsComputed.per * 0.02; + let gpMod = delta * task.priority * _crit * perBonus; + + if (task.streak) { + let currStreak = direction === 'down' ? task.streak - 1 : task.streak; + let streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc + let afterStreak = gpMod * streakBonus; + if (currStreak > 0 && gpMod > 0) { + user._tmp.streakBonus = afterStreak - gpMod; // keep this on-hand for later, so we can notify streak-bonus + } + + stats.gp += afterStreak; + } else { + stats.gp += gpMod; + } +} + +function _changeTaskValue (user, task, direction, times, cron) { + let addToDelta = 0; + + // If multiple days have passed, multiply times days missed + _.times(times, () => { + // Each iteration calculate the nextDelta, which is then accumulated in the total delta. + let nextDelta = !cron && direction === 'down' ? _calculateReverseDelta(task, direction) : _calculateDelta(task, direction, cron); + + if (task.type !== 'reward') { + if (user.preferences.automaticAllocation === true && user.preferences.allocationMode === 'taskbased' && !(task.type === 'todo' && direction === 'down')) { + user.stats.training[task.attribute] += nextDelta; + } + + if (direction === 'up') { // Make progress on quest based on STR + user.party.quest.progress.up = user.party.quest.progress.up || 0; + + if (task.type === 'todo' || task.type === 'daily') { + user.party.quest.progress.up += nextDelta * (1 + user._statsComputed.str / 200); + } else if (task.type === 'habit') { + user.party.quest.progress.up += nextDelta * (0.5 + user._statsComputed.str / 400); + } + } + + task.value += nextDelta; + } + + addToDelta += nextDelta; + }); + + return addToDelta; +} + +export default function scoreTask (options = {}, req) { + let {user, task, direction, times = 1, cron = false} = options; + let delta = 0; + let stats = { + gp: user.stats.gp, + hp: user.stats.hp, + exp: user.stats.exp, + }; + + // TODO return or pass to cb, don't add to user object + // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying + // the API consumer, then cleared afterwards + user._tmp = {}; + + // If they're trying to purhcase a too-expensive reward, don't allow them to do that. + if (task.value > user.stats.gp && task.type === 'reward') throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language)); + + // ===== 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); + } else { + _subtractPoints(user, task, stats, delta); + } + _gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + // history + let th = task.history; + let thl = task.history.length; + + if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) { + th[thl - 1].value = task.value; + } else { + th.push({ + date: Number(new Date()), // TODO are we going to cast history entries? + value: task.value, + }); + } + } else if (task.type === 'daily') { + if (cron) { + delta += _changeTaskValue(user, task, direction, times, cron); + _subtractPoints(user, task, stats, delta); + if (!user.stats.buffs.streaks) task.streak = 0; + } else { + 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 + _addPoints(user, task, stats, direction, delta); // obviously for delta>0, but also a trick to undo accidental checkboxes + _gainMP(user, _.max([1, 0.01 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1)); + + if (direction === 'up') { + 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 { + // 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; + } + } + } else if (task.type === 'todo') { + if (cron) { // don't touch stats on cron + delta += _changeTaskValue(user, task, direction, times, cron); + } else { + if (direction === 'up') task.dateCompleted = new Date(); + + 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 + _addPoints(user, task, stats, direction, delta); + + // MP++ per checklist item in ToDo, bonus per CLI + let multiplier = _.max([_.reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]); + _gainMP(user, _.max([multiplier, 0.01 * user._statsComputed.maxMP * multiplier]) * (direction === 'down' ? -1 : 1)); + } + } else if (task.type === 'reward') { + // Don't adjust values for rewards + delta += _changeTaskValue(user, task, direction, times, cron); + // purchase item + stats.gp -= Math.abs(task.value); + // hp - gp difference + if (stats.gp < 0) { + stats.hp += stats.gp; + stats.gp = 0; + } + } + + user.fns.updateStats(stats, req); + return delta; +} diff --git a/website/src/controllers/api-v3/tasks.js b/website/src/controllers/api-v3/tasks.js index f8ccd207d6..e61bd79965 100644 --- a/website/src/controllers/api-v3/tasks.js +++ b/website/src/controllers/api-v3/tasks.js @@ -9,6 +9,7 @@ import { import shared from '../../../../common'; import Q from 'q'; import _ from 'lodash'; +import scoreTask from '../../../../common/script/api-v3/scoreTask'; let api = {}; @@ -257,7 +258,15 @@ api.scoreTask = { task.completed = direction === 'up'; } - let delta = user.ops.score({params: {id: task._id, direction}, language: req.language}); + let delta; + try { + delta = scoreTask({task, user, direction}, req); + } catch (e) { + throw e; + } + + // Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results) + if (direction === 'up') user.fns.randomDrop({task, delta}, req); return Q.all([ user.save(), @@ -265,7 +274,7 @@ api.scoreTask = { ]).then((results) => { let savedUser = results[0]; - let userStats = savedUser.toJSON().stats; + let userStats = savedUser.stats.toJSON(); let resJsonData = _.extend({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData);