Merge pull request #6343 from HabitRPG/api-v3-tasks2

[API v3] Tasks 2
This commit is contained in:
Matteo Pagliazzi
2015-12-16 13:18:05 +01:00
33 changed files with 2225 additions and 320 deletions
+250
View File
@@ -0,0 +1,250 @@
import moment from 'moment';
import _ from 'lodash';
import scoreTask from './scoreTask';
import common from '../../';
import {
shouldDo,
} from '../cron';
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.
export default function cron (options = {}) {
let {user, tasks, tasksByType, analytics, now, daysMissed} = options;
user.auth.timestamps.loggedin = now;
user.lastCron = now;
// 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}); // 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'); // TODO necessary?
}
}
// 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
let completed = task.completed;
scoreTask({
task,
user,
direction: 'down',
cron: true,
times: multiDaysCountAsOneDay ? 1 : daysMissed,
// TODO pass req for analytics?
});
let absVal = completed ? Math.abs(task.value) : task.value;
todoTally += absVal;
});
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({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.
user.fns.preenUserHistory(tasks);
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);
_.merge(progress, {down: 0, up: 0});
progress.collect = _.transform(progress.collect, (m, v, k) => m[k] = 0);
// 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;
}
+81
View File
@@ -0,0 +1,81 @@
import moment from 'moment';
import _ from 'lodash';
function _preen (newHistory, history, amount, groupBy) {
_.chain(history)
.groupBy(h => moment(h.date).format(groupBy))
.sortBy((h, k) => k)
.slice(-amount)
.pop()
.each((group) => {
newHistory.push({
date: moment(group[0].date).toDate(),
value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length,
});
})
.value();
}
// Free users:
// Preen history for users with > 7 history entries
// This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array
// of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week
// of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite]
//
// Subscribers:
// TODO implement
// TODO Probably the description ^ is not too correct, this method actually takes 1 value each for the last 50 years,
// then the X last months, where X is the month we're in (september = 8 starting from 0)
// and all the days in this month
// Allowing for multiple values in a single day for habits we probably want something different:
// For free users:
// - At max 30 values for today (max 30)
// - 1 value each for the previous 61 days (2 months)
// - 1 value each for the previous 10 months (max 10)
// - 1 value each for the previous 50 years
// - Total: 30+61+10+ a few years ~= 105
//
// For subscribed users
// - At max 30 values for today (max 30)
// - 1 value each for the previous 364 days (max 364)
// - 1 value each for the previous 12 months (max 12)
// - 1 value each for the previous 50 years
// - Total: 30+364+12+ a few years ~= 410
//
export function preenHistory (history) {
// TODO remember to add this to migration
/* history = _.filter(history, function(h) {
return !!h;
}); */
let newHistory = [];
_preen(newHistory, history, 50, 'YYYY');
_preen(newHistory, history, moment().format('MM'), 'YYYYMM');
let thisMonth = moment().format('YYYYMM');
newHistory = newHistory.concat(history.filter(h => {
return moment(h.date).format('YYYYMM') === thisMonth;
}));
return newHistory;
}
export function preenUserHistory (user, tasksByType, minHistLen = 7) {
tasksByType.habits.concat(user.dailys).forEach((task) => {
if (task.history.length > minHistLen) {
task.history = preenHistory(user, task.history);
task.markModified('history');
}
});
if (user.history.exp.length > minHistLen) {
user.history.exp = preenHistory(user, user.history.exp);
user.markModified('history.exp');
}
if (user.history.todos.length > minHistLen) {
user.history.todos = preenHistory(user, user.history.todos);
user.markModified('history.todos');
}
}
+251
View File
@@ -0,0 +1,251 @@
import _ from 'lodash';
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));
// Add history entry, even more than 1 per day
task.history.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;
}
+15 -12
View File
@@ -2063,7 +2063,7 @@ api.wrap = function(user, main) {
return content.gear.flat[type + "_base_0"];
}
return item;
},
},
handleTwoHanded: function(item, type, req) {
var message, currentWeapon, currentShield;
if (type == null) {
@@ -2071,17 +2071,17 @@ api.wrap = function(user, main) {
}
currentShield = content.gear.flat[user.items.gear[type].shield];
currentWeapon = content.gear.flat[user.items.gear[type].weapon];
if (item.type === "shield" && (currentWeapon ? currentWeapon.twoHanded : false)) {
user.items.gear[type].weapon = 'weapon_base_0';
message = i18n.t('messageTwoHandedUnequip', {
twoHandedText: currentWeapon.text(req.language), offHandedText: item.text(req.language),
}, req.language);
} else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) {
user.items.gear[type].shield = "shield_base_0";
} else if (item.twoHanded && (currentShield && user.items.gear[type].shield != "shield_base_0")) {
user.items.gear[type].shield = "shield_base_0";
message = i18n.t('messageTwoHandedEquip', {
twoHandedText: item.text(req.language), offHandedText: currentShield.text(req.language),
}, req.language);
}, req.language);
}
return message;
},
@@ -2692,11 +2692,14 @@ api.wrap = function(user, main) {
return computed;
}
});
return Object.defineProperty(user, 'tasks', {
get: function() {
var tasks;
tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards);
return _.object(_.pluck(tasks, "id"), tasks);
}
});
if (typeof window !== 'undefined') {
Object.defineProperty(user, 'tasks', {
get: function() {
var tasks;
tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards);
return _.object(_.pluck(tasks, "id"), tasks);
}
});
}
};