Files
habitica/website/common/script/ops/scoreTask.js
SabreCat 149da578fd fix(teams): fix fix fix
Removed testing banner
Fixed a JS console error when assigning a user to a previously open task
Fixed a potential abuse where user might be able to score someone else's 
task via API call
Fixed an issue where finding tasks by alias could return tasks belonging 
to other users
Fixed an issue that was appending the user's party ID to their list of 
Guilds
Fixed an issue where group tasks were not receiving the default tag 
needed for filtering them on user's personal list
2022-08-22 16:16:23 -05:00

432 lines
16 KiB
JavaScript

import find from 'lodash/find';
import timesLodash from 'lodash/times';
import reduce from 'lodash/reduce';
import moment from 'moment';
import max from 'lodash/max';
import {
BadRequest,
NotAuthorized,
} from '../libs/errors';
import i18n from '../i18n';
import updateStats from '../fns/updateStats';
import crit from '../fns/crit';
import getUtcOffset from '../fns/getUtcOffset';
import statsComputed from '../libs/statsComputed';
import { checkOnboardingStatus } from '../libs/onboarding';
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;
} if (taskValue > MAX_TASK_VALUE) {
return MAX_TASK_VALUE;
}
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
const currVal = _getTaskValue(task.value);
let nextDelta = (0.9747 ** currVal) * (direction === 'down' ? -1 : 1);
// Checklists
if (task.checklist && task.checklist.length > 0) {
// 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;
}
// If To Do, point-match the TD per checklist item completed
if (task.type === 'todo' && !cron) {
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 value using the normal way for our first guess although
// it will be a bit off
function _calculateReverseDelta (task, direction) {
const currVal = _getTaskValue(task.value);
let testVal = currVal + (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
const calc = testVal + (0.9747 ** testVal);
const 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; // eslint-disable-line no-param-reassign
user.stats.mp += val;
if (user.stats.mp >= statsComputed(user).maxMP) user.stats.mp = statsComputed(user).maxMP;
if (user.stats.mp < 0) {
user.stats.mp = 0;
}
}
// HP modifier
// ===== CONSTITUTION =====
// TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point.
function _subtractPoints (user, task, stats, delta) {
if (task.group.id && task.type === 'daily') return stats.hp;
let conBonus = 1 - statsComputed(user).con / 250;
if (conBonus < 0.1) conBonus = 0.1;
const 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) {
const _crit = user._tmp.crit || 1;
// Exp Modifier
// ===== Intelligence =====
// TODO Increases Experience gain by .2% per point.
const intBonus = 1 + statsComputed(user).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.
const perBonus = 1 + statsComputed(user).per * 0.02;
const gpMod = delta * task.priority * _crit * perBonus;
if (task.streak) {
const currStreak = direction === 'down' ? task.streak - 1 : task.streak;
const streakBonus = currStreak / 100 + 1; // eg, 1-day streak is 1.01, 2-day is 1.02, etc
const afterStreak = gpMod * streakBonus;
if (currStreak > 0 && gpMod > 0) {
// keep this on-hand for later, so we can notify streak-bonus
user._tmp.streakBonus = afterStreak - gpMod;
}
stats.gp += afterStreak;
} else {
stats.gp += gpMod;
}
}
function _changeTaskValue (user, task, direction, times, cron) {
let addToDelta = 0;
// ===== CRITICAL HITS =====
// allow critical hit only when checking off a task, not when unchecking it:
const _crit = direction === 'up' ? crit.crit(user) : 1;
// if there was a crit, alert the user via notification
if (_crit > 1) user._tmp.crit = _crit;
// If multiple days have passed, multiply times days missed
timesLodash(times, () => {
// Each iteration calculate the nextDelta, which is then accumulated in the total delta.
const 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;
const prevProgress = user.party.quest.progress.up;
if (task.type === 'todo' || task.type === 'daily') {
user.party.quest.progress.up += nextDelta * _crit * (1 + statsComputed(user).str / 200);
} else if (task.type === 'habit') {
user.party.quest.progress.up += nextDelta * _crit * (0.5 + statsComputed(user).str / 400);
}
if (!user._tmp.quest) user._tmp.quest = {};
user._tmp.quest.progressDelta = user.party.quest.progress.up - prevProgress;
}
task.value += nextDelta;
}
addToDelta += nextDelta;
});
return addToDelta;
}
function _updateCounter (task, direction, times) {
if (direction === 'up') {
task.counterUp += times;
} else {
task.counterDown += times;
}
}
function _lastHistoryEntryWasToday (lastHistoryEntry, user) {
if (!lastHistoryEntry || !lastHistoryEntry.date) {
return false;
}
const timezoneUtcOffset = getUtcOffset(user);
const { dayStart } = user.preferences;
// Adjust the last entry date according to the user's timezone and CDS
const dateWithTimeZone = moment(lastHistoryEntry.date).utcOffset(timezoneUtcOffset);
if (dateWithTimeZone.hour() < dayStart) dateWithTimeZone.subtract(1, 'day');
return moment().utcOffset(timezoneUtcOffset).isSame(dateWithTimeZone, 'day');
}
function _updateLastHistoryEntry (lastHistoryEntry, task, direction, times) {
lastHistoryEntry.value = task.value;
lastHistoryEntry.date = Number(new Date());
// @TODO remove this extra check after migration
// has run to set scoredUp and scoredDown in every task
lastHistoryEntry.scoredUp = lastHistoryEntry.scoredUp || 0;
lastHistoryEntry.scoredDown = lastHistoryEntry.scoredDown || 0;
if (direction === 'up') {
lastHistoryEntry.scoredUp += times;
} else {
lastHistoryEntry.scoredDown += times;
}
}
export default function scoreTask (options = {}, req = {}, analytics) {
const {
user, task, direction, times = 1, cron = false,
} = options;
let delta = 0;
const stats = {
gp: user.stats.gp,
hp: user.stats.hp,
exp: user.stats.exp,
};
// This is for setting one-time temporary flags,
// such as streakBonus or itemDropped. Useful for notifying
// the API consumer, then cleared afterwards
// Keep user._tmp.leveledUp if it already exists
// To make sure infos on level ups don't get lost when bulk scoring multiple tasks
const oldLeveledUp = user._tmp && user._tmp.leveledUp;
user._tmp = {};
if (oldLeveledUp) user._tmp.leveledUp = oldLeveledUp;
// If they're trying to purchase 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));
// Thanks to open group tasks, userId is not guaranteed. Don't allow scoring inaccessible tasks
if (task.userId && task.userId !== user._id) {
throw new BadRequest('Cannot score task belonging to another user.');
} else if (user.guilds.indexOf(task.group.id) === -1 && user.party._id !== task.group.id) {
throw new BadRequest('Cannot score task belonging to another user.');
}
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 * statsComputed(user).maxMP]) * (direction === 'down' ? -1 : 1));
// Save history entry for habit
task.history = task.history || [];
const historyLength = task.history.length;
const lastHistoryEntry = task.history[historyLength - 1];
if (_lastHistoryEntryWasToday(lastHistoryEntry, user)) {
_updateLastHistoryEntry(lastHistoryEntry, task, direction, times);
if (task.markModified) {
task.markModified(`history.${historyLength - 1}`);
}
} else {
task.history.push({
date: Number(new Date()),
value: task.value,
scoredUp: direction === 'up' ? 1 : 0,
scoredDown: direction === 'down' ? 1 : 0,
});
}
_updateCounter(task, direction, times);
} else if (task.type === 'daily') {
if (cron) {
delta += _changeTaskValue(user, task, direction, times, cron);
_subtractPoints(user, task, stats, delta);
// Chilling frost should not affect challenge or group dailies
if (!user.stats.buffs.streaks || task.challenge.id || task.group.id) task.streak = 0;
} else {
delta += _changeTaskValue(user, task, direction, times, cron);
if (direction === 'down') delta = _calculateDelta(task, direction, cron); // recalculate delta for unchecking so the gp and exp come out correctly
// obviously for delta>0, but also a trick to undo accidental checkboxes
_addPoints(user, task, stats, direction, delta);
_gainMP(user, max([1, 0.01 * statsComputed(user).maxMP]) * (direction === 'down' ? -1 : 1));
if (direction === 'up') {
if (task.group.id) {
if (!task.group.assignedUsers || task.group.assignedUsers.length === 0) {
task.group.completedBy = {
userId: user._id,
date: new Date(),
};
task.completed = true;
task.streak += 1;
} else {
task.group.assignedUsersDetail[user._id].completed = true;
task.group.assignedUsersDetail[user._id].completedDate = new Date();
if (!find(task.group.assignedUsersDetail, assignedUser => !assignedUser.completed)) {
task.dateCompleted = new Date();
task.completed = true;
task.streak += 1;
}
}
if (task.markModified) task.markModified('group');
} else {
task.streak += 1;
// Give a streak achievement when the streak is a multiple of 21
if (task.streak !== 0 && task.streak % 21 === 0) {
user.achievements.streak = user.achievements.streak ? user.achievements.streak + 1 : 1;
if (user.addNotification) user.addNotification('STREAK_ACHIEVEMENT');
}
task.completed = true;
// Save history entry for daily
task.history = task.history || [];
const historyEntry = {
date: Number(new Date()),
value: task.value,
isDue: task.isDue,
completed: true,
};
task.history.push(historyEntry);
}
} else if (direction === 'down') {
if (task.group.id) {
if (!task.group.assignedUsersDetail
|| !find(task.group.assignedUsersDetail, assignedUser => !assignedUser.completed)
) {
task.streak -= 1;
task.completed = false;
}
if (task.group.completedBy) task.group.completedBy = {};
if (task.group.assignedUsersDetail && task.group.assignedUsersDetail[user._id]) {
task.group.assignedUsersDetail[user._id].completed = false;
task.group.assignedUsersDetail[user._id].completedDate = undefined;
}
if (task.markModified) task.markModified('group');
} else {
// Remove a streak achievement if streak was a multiple of 21 and the daily was undone
if (task.streak !== 0 && task.streak % 21 === 0) {
user.achievements.streak = user.achievements.streak ? user.achievements.streak - 1 : 0;
}
task.streak -= 1;
task.completed = false;
// Delete history entry when daily unchecked
if (task.history || task.history.length > 0) {
task.history.splice(-1, 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') {
if (task.group.id) {
if (!task.group.assignedUsers || task.group.assignedUsers.length === 0) {
task.group.completedBy = {
userId: user._id,
date: new Date(),
};
task.completed = true;
} else {
task.group.assignedUsersDetail[user._id].completed = true;
task.group.assignedUsersDetail[user._id].completedDate = new Date();
if (!find(task.group.assignedUsersDetail, assignedUser => !assignedUser.completed)) {
task.dateCompleted = new Date();
task.completed = true;
}
}
if (task.markModified) task.markModified('group');
} else {
task.dateCompleted = new Date();
task.completed = true;
}
} else if (direction === 'down') {
task.completed = false;
task.dateCompleted = undefined;
if (task.group.id) {
if (task.group.completedBy) task.group.completedBy = {};
if (task.group.assignedUsersDetail && task.group.assignedUsersDetail[user._id]) {
task.group.assignedUsersDetail[user._id].completed = false;
task.group.assignedUsersDetail[user._id].completedDate = undefined;
}
if (task.markModified) task.markModified('group');
}
}
delta += _changeTaskValue(user, task, direction, times, cron);
if (direction === 'down') delta = _calculateDelta(task, direction, cron); // 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
const multiplier = max([reduce(task.checklist, (m, i) => m + (i.completed ? 1 : 0), 1), 1]);
_gainMP(user, max([multiplier, 0.01 * statsComputed(user).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 -= task.value;
}
req.yesterDailyScored = task.yesterDailyScored;
updateStats(user, stats, req);
if (!user.achievements.completedTask && cron === false && direction === 'up' && user.addAchievement) {
user.addAchievement('completedTask');
checkOnboardingStatus(user, req, analytics);
}
return delta;
}