From 13a70194133bd94908ef1b9e082dd8a03879f40a Mon Sep 17 00:00:00 2001 From: Tyler Renelle Date: Mon, 24 Sep 2012 16:19:27 -0400 Subject: [PATCH] setup for async cron --- lib/app/scoring.js | 151 +++++++++++++++++++++-------------------- package.json | 4 +- src/app/scoring.coffee | 92 ++++++++++++++----------- test/user.mocha.coffee | 80 ++++++++++++++++++---- 4 files changed, 200 insertions(+), 127 deletions(-) diff --git a/lib/app/scoring.js b/lib/app/scoring.js index d285617daa..95c702d329 100644 --- a/lib/app/scoring.js +++ b/lib/app/scoring.js @@ -1,5 +1,7 @@ // Generated by CoffeeScript 1.3.3 -var MODIFIER, content, cron, expModifier, helpers, hpModifier, model, moment, score, setModel, setupNotifications, tally, updateStats, user; +var MODIFIER, async, content, cron, expModifier, helpers, hpModifier, model, moment, score, setModel, setupNotifications, updateStats, user; + +async = require('async'); moment = require('moment'); @@ -122,17 +124,20 @@ updateStats = function(stats) { } }; -score = function(taskId, direction, cron) { - var adjustvalue, delta, exp, hp, lvl, modified, money, num, sign, task, taskObj, taskPath, type, userObj, value, _ref, _ref1, _ref2, _ref3; - if (cron == null) { - cron = false; +score = function(taskId, direction, options) { + var adjustvalue, delta, exp, hp, lvl, modified, money, num, sign, task, taskObj, taskPath, type, userObj, value, _ref, _ref1, _ref2; + if (options == null) { + options = { + cron: false, + times: 1 + }; } taskPath = "_user.tasks." + taskId; _ref = [model.at(taskPath), model.get(taskPath)], task = _ref[0], taskObj = _ref[1]; - _ref1 = [taskObj.type, taskObj.value], type = _ref1[0], value = _ref1[1]; + type = taskObj.type, value = taskObj.value; userObj = user.get(); if (!task) { - _ref2 = [userObj.stats.money, userObj.stats.hp, userObj.stats.exp], money = _ref2[0], hp = _ref2[1], exp = _ref2[2]; + _ref1 = userObj.stats, money = _ref1.money, hp = _ref1.hp, exp = _ref1.exp; if (direction === "up") { modified = expModifier(1); money += modified; @@ -157,6 +162,7 @@ score = function(taskId, direction, cron) { if (adjustvalue) { value += delta; } + value *= options.times; if (type === 'habit') { if (taskObj.value !== value) { task.push('history', { @@ -166,7 +172,7 @@ score = function(taskId, direction, cron) { } } task.set('value', value); - _ref3 = [userObj.stats.money, userObj.stats.hp, userObj.stats.exp, userObj.stats.lvl], money = _ref3[0], hp = _ref3[1], exp = _ref3[2], lvl = _ref3[3]; + _ref2 = [userObj.stats.money, userObj.stats.hp, userObj.stats.exp, userObj.stats.lvl], money = _ref2[0], hp = _ref2[1], exp = _ref2[2], lvl = _ref2[3]; if (type === 'reward') { money -= task.get('value'); num = parseFloat(task.get('value')).toFixed(2); @@ -175,7 +181,7 @@ score = function(taskId, direction, cron) { money = 0; } } - if ((delta > 0 || (type === 'daily' || type === 'todo')) && !cron) { + if ((delta > 0 || (type === 'daily' || type === 'todo')) && !options.cron) { modified = expModifier(delta); exp += modified; money += modified; @@ -192,78 +198,79 @@ score = function(taskId, direction, cron) { }; cron = function() { - var daysPassed, lastCron, today; + var daysPassed, lastCron, tallyTask, tasks, today, todoTally; today = moment().sod(); user.setNull('lastCron', today.toDate()); lastCron = moment(user.get('lastCron')); daysPassed = today.diff(lastCron, 'days'); if (daysPassed > 0) { - user.set('lastCron', today.toDate()); - return _.times(daysPassed, function(n) { - var tallyFor; - tallyFor = lastCron.add('d', n); - return tally(tallyFor); - }); - } -}; - -tally = function(momentDate) { - var expTally, lvl, todoTally; - todoTally = 0; - _.each(user.get('tasks'), function(taskObj, taskId, list) { - var absVal, completed, dayMapping, dueToday, repeat, task, type, value, _ref; - if (taskObj.id == null) { - return; - } - _ref = [taskObj.type, taskObj.value, taskObj.completed, taskObj.repeat], type = _ref[0], value = _ref[1], completed = _ref[2], repeat = _ref[3]; - task = user.at("tasks." + taskId); - if (type === 'todo' || type === 'daily') { - if (!completed) { - dayMapping = { - 0: 'su', - 1: 'm', - 2: 't', - 3: 'w', - 4: 'th', - 5: 'f', - 6: 's', - 7: 'su' - }; - dueToday = repeat && repeat[dayMapping[momentDate.day()]] === true; - if (dueToday || type === 'todo') { - score(taskId, 'down', true); + todoTally = 0; + tallyTask = function(taskObj, next) { + var absVal, completed, dayMapping, daysFailed, dueToday, id, repeat, task, type, value; + id = taskObj.id, type = taskObj.type, completed = taskObj.completed, repeat = taskObj.repeat; + if (id == null) { + return; + } + task = user.at("tasks." + id); + if (type === 'todo' || type === 'daily') { + if (!completed) { + daysFailed = daysPassed; + if (type === 'daily' && repeat) { + dayMapping = { + 0: 'su', + 1: 'm', + 2: 't', + 3: 'w', + 4: 'th', + 5: 'f', + 6: 's', + 7: 'su' + }; + dueToday = repeat && repeat[dayMapping[momentDate.day()]] === true; + } + score(taskId, 'down', { + cron: true, + times: daysFailed + }); + } + value = task.get('value'); + if (type === 'daily') { + task.push("history", { + date: today.toDate(), + value: value + }); + } else { + absVal = completed ? Math.abs(value) : value; + todoTally += absVal; + } + if (type === 'daily') { + task.pass({ + cron: true + }).set('completed', false); } } - if (type === 'daily') { - task.push("history", { - date: new Date(momentDate), - value: value - }); - } else { - absVal = completed ? Math.abs(value) : value; - todoTally += absVal; + return next(); + }; + tasks = _.toArray(user.get('tasks')); + return async.forEach(tasks, tallyTask, function(err) { + var expTally, lvl; + user.push('history.todos', { + date: today.toDate(), + value: todoTally + }); + expTally = user.get('stats.exp'); + lvl = 0; + while (lvl < (user.get('stats.lvl') - 1)) { + lvl++; + expTally += (lvl * 100) / 5; } - if (type === 'daily') { - return task.pass({ - cron: true - }).set('completed', false); - } - } - }); - user.push('history.todos', { - date: new Date(momentDate), - value: todoTally - }); - expTally = user.get('stats.exp'); - lvl = 0; - while (lvl < (user.get('stats.lvl') - 1)) { - lvl++; - expTally += (lvl * 100) / 5; + user.push('history.exp', { + date: today.toDate(), + value: expTally + }); + return user.set('lastCron', today.toDate()); + }); } - return user.push('history.exp', { - date: new Date(), - value: expTally - }); }; module.exports = { diff --git a/package.json b/package.json index 0e00b4b001..3142084099 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "guid": "*", "node.extend": "*", "moment": "*", - "stripe": "*" + "stripe": "*", + "async": "*", + "lodash": "*" }, "private": true, "devDependencies": { diff --git a/src/app/scoring.coffee b/src/app/scoring.coffee index 139e445c07..f0e13210ad 100644 --- a/src/app/scoring.coffee +++ b/src/app/scoring.coffee @@ -1,3 +1,4 @@ +async = require 'async' moment = require 'moment' content = require './content' helpers = require './helpers' @@ -107,17 +108,17 @@ updateStats = (stats) -> # {taskId} task you want to score # {direction} 'up' or 'down' # {cron} is this function being called by cron? (this will usually be false) -score = (taskId, direction, cron=false) -> +score = (taskId, direction, options={cron:false, times:1}) -> taskPath = "_user.tasks.#{taskId}" [task, taskObj] = [model.at(taskPath), model.get(taskPath)] - [type, value] = [taskObj.type, taskObj.value] + {type, value} = taskObj userObj = user.get() # up / down was called by itself, probably as REST from 3rd party service #FIXME handle this if !task - [money, hp, exp] = [userObj.stats.money, userObj.stats.hp, userObj.stats.exp] + {money, hp, exp} = userObj.stats if (direction == "up") modified = expModifier(1) money += modified @@ -140,6 +141,9 @@ score = (taskId, direction, cron=false) -> if (type == 'habit') and (taskObj.up==false or taskObj.down==false) adjustvalue = false value += delta if adjustvalue + + # If multiple days have passed, multiply times days missed + value *= options.times if type == 'habit' # Add habit value to habit-history (if different) @@ -160,7 +164,7 @@ score = (taskId, direction, cron=false) -> # Add points to exp & money if positive delta # Only take away mony if it was a mistake (aka, a checkbox) - if (delta > 0 or (type in ['daily', 'todo'])) and !cron + if (delta > 0 or (type in ['daily', 'todo'])) and !options.cron modified = expModifier(delta) exp += modified money += modified @@ -172,50 +176,58 @@ score = (taskId, direction, cron=false) -> updateStats({hp: hp, exp: exp, money: money}) return delta + +# At end of day, add value to all incomplete Daily & Todo tasks (further incentive) +# For incomplete Dailys, deduct experience cron = -> today = moment().sod() # start of day user.setNull 'lastCron', today.toDate() lastCron = moment(user.get('lastCron')) daysPassed = today.diff(lastCron, 'days') if daysPassed > 0 - user.set('lastCron', today.toDate()) # reset cron - _.times daysPassed, (n) -> - tallyFor = lastCron.add('d',n) - tally(tallyFor) + # Tally function, which is called asyncronously below - but function is defined here. + # We need access to some closure variables above + todoTally = 0 + tallyTask = (taskObj, next) -> + {id, type, completed, repeat} = taskObj + return unless id? #this shouldn't be happening, some tasks seem to be corrupted + task = user.at("tasks.#{id}") + if type in ['todo', 'daily'] + # Deduct experience for missed Daily tasks, + # but not for Todos (just increase todo's value) + unless completed + # for todos & typical dailies, these are equivalent + daysFailed = daysPassed + # however, for dailys which have repeat dates, need + # to calculate how many they've missed according to their own schedule + if type=='daily' && repeat + dayMapping = {0:'su',1:'m',2:'t',3:'w',4:'th',5:'f',6:'s',7:'su'} + dueToday = (repeat && repeat[dayMapping[momentDate.day()]]==true) + score(taskId, 'down', {cron:true, times: daysFailed}) -# At end of day, add value to all incomplete Daily & Todo tasks (further incentive) -# For incomplete Dailys, deduct experience -tally = (momentDate) -> - todoTally = 0 - _.each user.get('tasks'), (taskObj, taskId, list) -> - #FIXME is it hiccuping here? taskId == "$_65255f4e-3728-4d50-bade-3b05633639af_2", & taskObj.id = undefined - return unless taskObj.id? #this shouldn't be happening, some tasks seem to be corrupted - [type, value, completed, repeat] = [taskObj.type, taskObj.value, taskObj.completed, taskObj.repeat] - task = user.at("tasks.#{taskId}") - if type in ['todo', 'daily'] - # Deduct experience for missed Daily tasks, - # but not for Todos (just increase todo's value) - unless completed - dayMapping = {0:'su',1:'m',2:'t',3:'w',4:'th',5:'f',6:'s',7:'su'} - dueToday = (repeat && repeat[dayMapping[momentDate.day()]]==true) - if dueToday or type=='todo' - score(taskId, 'down', true) - if type == 'daily' - task.push "history", { date: new Date(momentDate), value: value } - else - absVal = if (completed) then Math.abs(value) else value - todoTally += absVal - task.pass({cron:true}).set('completed', false) if type == 'daily' - user.push 'history.todos', { date: new Date(momentDate), value: todoTally } - - # tally experience - expTally = user.get 'stats.exp' - lvl = 0 #iterator - while lvl < (user.get('stats.lvl')-1) - lvl++ - expTally += (lvl*100)/5 - user.push 'history.exp', { date: new Date(), value: expTally } + value = task.get('value') #get updated value + if type == 'daily' + task.push "history", { date: today.toDate(), value: value } + else + absVal = if (completed) then Math.abs(value) else value + todoTally += absVal + task.pass({cron:true}).set('completed', false) if type == 'daily' + next() + + # Tally each task + tasks = _.toArray(user.get('tasks')) + async.forEach tasks, tallyTask, (err) -> + # Finished tallying, this is the 'completed' callback + user.push 'history.todos', { date: today.toDate(), value: todoTally } + # tally experience + expTally = user.get 'stats.exp' + lvl = 0 #iterator + while lvl < (user.get('stats.lvl')-1) + lvl++ + expTally += (lvl*100)/5 + user.push 'history.exp', { date: today.toDate(), value: expTally } + user.set('lastCron', today.toDate()) # reset cron module.exports = { diff --git a/test/user.mocha.coffee b/test/user.mocha.coffee index 91c4eb816b..f6f64f6fa5 100644 --- a/test/user.mocha.coffee +++ b/test/user.mocha.coffee @@ -5,14 +5,23 @@ derby = require 'derby' # Custom modules scoring = require '../src/app/scoring' schema = require '../src/app/schema' -_ = require '../public/js/underscore-min' +_ = require 'lodash' +moment = require 'moment' + +###### Helper Functions ###### modifictionLookup = (value, direction) -> #TODO implement a lookup table to test if user stats & task value has been modified properly + +###### Specs ###### describe 'User', -> model = null + ## Helper which clones the content at a path so tests can compare before/after values + pathSnapshots = (paths) -> + _.map paths, (path) -> _.clone(model.get(path)) + beforeEach -> model = new Model model.set '_user', schema.newUserObject() @@ -35,11 +44,18 @@ describe 'User', -> uuid = null taskPath = null + before -> + # Reset tasks + model.set '_user.tasks', {} + model.set '_user.habitIds', [] + model.set '_user.dailyIds', [] + model.set '_user.todoIds', [] + model.set '_user.rewardIds', [] + describe 'Habits', -> beforeEach -> # create a test task - user = model.get('_user') uuid = derby.uuid() taskPath = "_user.tasks.#{uuid}" model.refList "_habitList", "_user.tasks", "_user.habitIds" @@ -106,27 +122,63 @@ describe 'User', -> it 'should show "undo" notification if user unchecks completed daily' - describe 'Dailies', -> + describe 'Dailies', -> + + beforeEach -> + # create a test task + uuid = derby.uuid() + taskPath = "_user.tasks.#{uuid}" + model.refList "_dailyList", "_user.tasks", "_user.dailyIds" + model.at('_dailyList').push {type: 'daily', text: 'Daily', value: 0, completed: false, id: uuid } + + it 'created the daily', -> + task = model.get(taskPath) + expect(task.text).to.eql 'Daily' + expect(task.value).to.eql 0 + + it 'does proper calculations when daily is complete' + + it 'calculates user.stats & task.value properly on cron', -> + [statsBefore, taskBefore] = pathSnapshots(['_user.stats', taskPath]) + # Set lastCron to yesterday + today = moment() + model.set '_user.lastCron', today.subtract('days',1).toDate() + # Run run + scoring.cron() + [statsAfter, taskAfter] = pathSnapshots(['_user.stats', taskPath]) + + # Should have updated cron to today + lastCron = moment(model.get('_user.lastCron')) + expect(today.diff(lastCron, 'days')).to.eql 0 + + # Should have updated points properly + expect(statsBefore.hp).to.be.lessThan statsAfter.hp + expect(taskBefore.value).to.eql 0 + expect(taskAfter.value).to.eql -1 + #TODO clicking repeat dates on newly-created item doesn't refresh until you refresh the page #TODO dates on dailies is having issues, possibility: date cusps? my saturday exempts were set to exempt at 8pm friday - #TODO refactor as user->habits, user->dailys, user->todos, user->rewards + describe 'Todos', -> + describe 'Cron', -> + it 'should calculate user.stats & task.value properly on cron' + it 'should calculate cron based on difference between start-of-days, and not run in the middle of the day' + it 'should only run set operations once per task, even when daysPassed > 1' + # pass in daysPassed to score, multiply modification values by daysPassed before running set + it 'should only push a history point for lastCron, not each day in between' + # stop passing in tallyFor, let moment().sod().toDate() be handled in scoring.score() + it 'should defer saving user modifications until, save as aggregate values' + # pass in commit parameter to scoring func, if true save right away, otherwise return aggregated array so can save in the end (so total hp loss, etc) + + describe 'Rewards', -> describe 'Lvl & Items', -> it 'modified damage based on lvl & armor' it 'always decreases hp with damage, regardless of stats/items' it 'always increases exp/gp with gain, regardless of stats/items' - - describe 'Cron', -> - it 'should calculate user.stats & task.value properly on cron' - it 'should calculate cron based on difference between start-of-days, and not run in the middle of the day' - it 'should only run set operations once per task, even when daysPassed > 1' - # pass in daysPassed to score, multiply modification values by daysPassed before running set - it 'should only push a history point for lastCron, not each day in between' - # stop passing in tallyFor, let moment().sod().toDate() be handled in scoring.score() - it 'should defer saving user modifications until, save as aggregate values' - # pass in commit parameter to scoring func, if true save right away, otherwise return aggregated array so can save in the end (so total hp loss, etc) #### Require.js stuff, might be necessary to place in casper.coffee it "doesn't setup dependent functions until their modules are loaded, require.js callback" # sortable, stripe, etc + +#TODO refactor as user->habits, user->dailys, user->todos, user->rewards \ No newline at end of file