mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 14:47:53 +01:00
port cron and preening
This commit is contained in:
64
common/script/api-v3/preenHistory.js
Normal file
64
common/script/api-v3/preenHistory.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
function _preen (newHistory, history, amount, groupBy) {
|
||||||
|
let groups = _.chain(history)
|
||||||
|
.groupBy(h => moment(h.date).format(groupBy))
|
||||||
|
.sortBy((h, k) => k)
|
||||||
|
.value();
|
||||||
|
|
||||||
|
groups = groups.slice(-amount);
|
||||||
|
groups.pop();
|
||||||
|
|
||||||
|
_.each(groups, (group) => {
|
||||||
|
newHistory.push({
|
||||||
|
date: moment(group[0].date).toDate(),
|
||||||
|
value: _.reduce(group, (m, obj) => m + obj.value, 0) / group.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import moment from 'moment';
|
|
||||||
import {
|
import {
|
||||||
NotAuthorized,
|
NotAuthorized,
|
||||||
} from '../../../website/src/libs/api-v3/errors';
|
} from '../../../website/src/libs/api-v3/errors';
|
||||||
@@ -195,18 +194,11 @@ export default function scoreTask (options = {}, req = {}) {
|
|||||||
}
|
}
|
||||||
_gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1));
|
_gainMP(user, _.max([0.25, 0.0025 * user._statsComputed.maxMP]) * (direction === 'down' ? -1 : 1));
|
||||||
|
|
||||||
// history
|
// Add history entry, even more than 1 per day
|
||||||
let th = task.history;
|
task.history.push({
|
||||||
let thl = task.history.length;
|
|
||||||
|
|
||||||
if (th[thl - 1] && moment(th[thl - 1].date).isSame(new Date(), 'day')) {
|
|
||||||
th[thl - 1].value = task.value; // TODO mark modified?
|
|
||||||
} else {
|
|
||||||
th.push({
|
|
||||||
date: Number(new Date()), // TODO are we going to cast history entries?
|
date: Number(new Date()), // TODO are we going to cast history entries?
|
||||||
value: task.value,
|
value: task.value,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else if (task.type === 'daily') {
|
} else if (task.type === 'daily') {
|
||||||
if (cron) {
|
if (cron) {
|
||||||
delta += _changeTaskValue(user, task, direction, times, cron);
|
delta += _changeTaskValue(user, task, direction, times, cron);
|
||||||
|
|||||||
309
website/src/middlewares/api-v3/cron.js
Normal file
309
website/src/middlewares/api-v3/cron.js
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import {
|
||||||
|
daysSince,
|
||||||
|
shouldDo,
|
||||||
|
} from '../../../../common/script/cron';
|
||||||
|
import common from '../../../../common';
|
||||||
|
import scoreTask from '../../../../common/script/api-v3/scoreTask';
|
||||||
|
import moment from 'moment';
|
||||||
|
import Task from '../../models/task';
|
||||||
|
// import Group from '../../models/group';
|
||||||
|
|
||||||
|
function _runCron (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;
|
||||||
|
|
||||||
|
let clearBuffs = {
|
||||||
|
str: 0,
|
||||||
|
int: 0,
|
||||||
|
per: 0,
|
||||||
|
con: 0,
|
||||||
|
stealth: 0,
|
||||||
|
streaks: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (req, res, next) {
|
||||||
|
let user = res.locals.user;
|
||||||
|
let analytics = res.analytics;
|
||||||
|
|
||||||
|
let now = new Date();
|
||||||
|
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
|
||||||
|
|
||||||
|
if (daysMissed <= 0) return next(null, user); // TODO why are we passing user down here?
|
||||||
|
|
||||||
|
// Fetch active tasks (no completed todos)
|
||||||
|
Task.find({
|
||||||
|
userId: user._id,
|
||||||
|
$or: [ // Exclude completed todos
|
||||||
|
{type: 'todo', completed: false},
|
||||||
|
{type: {$in: ['habit', 'daily', 'reward']}},
|
||||||
|
],
|
||||||
|
}).exec()
|
||||||
|
.then((tasks) => {
|
||||||
|
let tasksByType = {habits: [], dailys: [], todos: [], rewards: []};
|
||||||
|
tasks.forEach(task => tasksByType[`${task.type}s`].push(task));
|
||||||
|
|
||||||
|
// Run cron
|
||||||
|
_runCron({user, tasks, tasksByType, now, daysMissed, analytics});
|
||||||
|
|
||||||
|
let ranCron = user.isModified();
|
||||||
|
let quest = common.content.quests[user.party.quest.key];
|
||||||
|
|
||||||
|
// if (ranCron) res.locals.wasModified = true; // TODO remove?
|
||||||
|
if (!ranCron) return next(null, user); // TODO why are we passing user to next?
|
||||||
|
// TODO Group.tavernBoss(user, progress);
|
||||||
|
if (!quest || true /* TODO remove */) return user.save(next);
|
||||||
|
|
||||||
|
// If user is on a quest, roll for boss & player, or handle collections
|
||||||
|
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
|
||||||
|
// TODO do
|
||||||
|
/* async.waterfall([
|
||||||
|
function(cb){
|
||||||
|
user.save(cb); // make sure to save the cron effects
|
||||||
|
},
|
||||||
|
function(saved, count, cb){
|
||||||
|
var type = quest.boss ? 'boss' : 'collect';
|
||||||
|
Group[type+'Quest'](user,progress,cb);
|
||||||
|
},
|
||||||
|
function(){
|
||||||
|
var cb = arguments[arguments.length-1];
|
||||||
|
// User has been updated in boss-grapple, reload
|
||||||
|
User.findById(user._id, cb);
|
||||||
|
}
|
||||||
|
], function(err, saved) {
|
||||||
|
res.locals.user = saved;
|
||||||
|
next(err,saved);
|
||||||
|
user = progress = quest = null;
|
||||||
|
});*/
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export let schema = new Schema({
|
|||||||
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
|
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which
|
||||||
// have been updated (http://goo.gl/gQLz41), but we want *every* update
|
// have been updated (http://goo.gl/gQLz41), but we want *every* update
|
||||||
_v: { type: Number, default: 0 },
|
_v: { type: Number, default: 0 },
|
||||||
|
// TODO give all this a default of 0?
|
||||||
achievements: {
|
achievements: {
|
||||||
originalUser: Boolean,
|
originalUser: Boolean,
|
||||||
habitSurveys: Number,
|
habitSurveys: Number,
|
||||||
@@ -65,7 +66,7 @@ export let schema = new Schema({
|
|||||||
quests: Schema.Types.Mixed, // TODO remove, use dictionary?
|
quests: Schema.Types.Mixed, // TODO remove, use dictionary?
|
||||||
rebirths: Number,
|
rebirths: Number,
|
||||||
rebirthLevel: Number,
|
rebirthLevel: Number,
|
||||||
perfect: Number,
|
perfect: {type: Number, default: 0},
|
||||||
habitBirthdays: Number,
|
habitBirthdays: Number,
|
||||||
valentine: Number,
|
valentine: Number,
|
||||||
costumeContest: Boolean, // Superseded by costumeContests
|
costumeContest: Boolean, // Superseded by costumeContests
|
||||||
@@ -627,6 +628,11 @@ schema.pre('save', true, function preSaveUser (next, done) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO unit test this?
|
||||||
|
schema.methods.isSubscribed = function isSubscribed () {
|
||||||
|
return !!this.purchased.plan.customerId; // eslint-disable-line no-implicit-coercion
|
||||||
|
};
|
||||||
|
|
||||||
schema.methods.unlink = function unlink (options, cb) {
|
schema.methods.unlink = function unlink (options, cb) {
|
||||||
let cid = options.cid;
|
let cid = options.cid;
|
||||||
let keep = options.keep;
|
let keep = options.keep;
|
||||||
|
|||||||
Reference in New Issue
Block a user