diff --git a/src/app/index.coffee b/src/app/index.coffee index 55ebf7cc2f..b25e52cb9d 100644 --- a/src/app/index.coffee +++ b/src/app/index.coffee @@ -45,13 +45,13 @@ get '/', (page, model, params, next) -> ready (model) -> user = model.at('_user') - scoring.setModel(model) + score = new scoring.Scoring(model) #set cron immediately lastCron = user.get('lastCron') user.set('lastCron', +new Date) if (!lastCron? or lastCron == 'new') - scoring.cron() + score.cron() character.app(exports, model) tasks.app(exports, model) diff --git a/src/app/scoring.coffee b/src/app/scoring.coffee index 2eb2065c60..4f1b78db70 100644 --- a/src/app/scoring.coffee +++ b/src/app/scoring.coffee @@ -5,273 +5,269 @@ helpers = require './helpers' browser = require './browser' character = require './character' items = require './items' -MODIFIER = .02 # each new level, armor, weapon add 2% modifier (this number may change) -user = undefined -model = undefined -# This is required by all the functions, make sure it's set before anythign else is called -setModel = (m) -> - model = m - user = model.at('_user') +module.exports.Scoring = (model) -> -### - Calculates Exp & GP modification based on weapon & lvl - {value} task.value for gain - {modifiers} may manually pass in stats as {weapon, exp}. This is used for testing -### -expModifier = (value, modifiers = {}) -> - weapon = modifiers.weapon || user.get('items.weapon') - lvl = modifiers.lvl || user.get('stats.lvl') - dmg = items.items.weapon[weapon].modifier # each new weapon increases exp gain - dmg += (lvl-1) * MODIFIER # same for lvls - modified = value + (value * dmg) - return modified + MODIFIER = .02 # each new level, armor, weapon add 2% modifier (this mechanism will change) + user = model.at '_user' -### - Calculates HP-loss modification based on armor & lvl - {value} task.value which is hurting us - {modifiers} may manually pass in modifier as {armor, lvl}. This is used for testing -### -hpModifier = (value, modifiers = {}) -> - armor = modifiers.armor || user.get('items.armor') - head = modifiers.head || user.get('items.head') - shield = modifiers.shield || user.get('items.shield') - lvl = modifiers.lvl || user.get('stats.lvl') - ac = items.items.armor[armor].modifier + items.items.head[head].modifier + items.items.shield[shield].modifier # each new armor decreases HP loss - ac += (lvl-1) * MODIFIER # same for lvls - modified = value - (value * ac) - return modified - -### - Calculates the next task.value based on direction - For negative values, use a line: something like y=-.1x+1 - For positibe values, taper off with inverse log: y=.9^x - Would love to use inverse log for the whole thing, but after 13 fails it hits infinity. Revisit this formula later - {currentValue} the current value of the task, determines it's next value - {direction} 'up' or 'down' -### -taskDeltaFormula = (currentValue, direction) -> - sign = if (direction == "up") then 1 else -1 - delta = if (currentValue < 0) then (( -0.1 * currentValue + 1 ) * sign) else (( Math.pow(0.9,currentValue) ) * sign) - return delta + ### + Calculates Exp & GP modification based on weapon & lvl + {value} task.value for gain + {modifiers} may manually pass in stats as {weapon, exp}. This is used for testing + ### + expModifier = (value, modifiers = {}) -> + weapon = modifiers.weapon || user.get('items.weapon') + lvl = modifiers.lvl || user.get('stats.lvl') + dmg = items.items.weapon[weapon].modifier # each new weapon increases exp gain + dmg += (lvl-1) * MODIFIER # same for lvls + modified = value + (value * dmg) + return modified -### - Updates user stats with new stats. Handles death, leveling up, etc - {stats} new stats - {update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately -### -updateStats = (newStats, batch) -> - obj = batch.obj() + ### + Calculates HP-loss modification based on armor & lvl + {value} task.value which is hurting us + {modifiers} may manually pass in modifier as {armor, lvl}. This is used for testing + ### + hpModifier = (value, modifiers = {}) -> + armor = modifiers.armor || user.get('items.armor') + head = modifiers.head || user.get('items.head') + shield = modifiers.shield || user.get('items.shield') + lvl = modifiers.lvl || user.get('stats.lvl') + ac = items.items.armor[armor].modifier + items.items.head[head].modifier + items.items.shield[shield].modifier # each new armor decreases HP loss + ac += (lvl-1) * MODIFIER # same for lvls + modified = value - (value * ac) + return modified - # if user is dead, dont do anything - return if obj.stats.lvl == 0 + ### + Calculates the next task.value based on direction + For negative values, use a line: something like y=-.1x+1 + For positibe values, taper off with inverse log: y=.9^x + Would love to use inverse log for the whole thing, but after 13 fails it hits infinity. Revisit this formula later + {currentValue} the current value of the task, determines it's next value + {direction} 'up' or 'down' + ### + taskDeltaFormula = (currentValue, direction) -> + sign = if (direction == "up") then 1 else -1 + delta = if (currentValue < 0) then (( -0.1 * currentValue + 1 ) * sign) else (( Math.pow(0.9,currentValue) ) * sign) + return delta - if newStats.hp? - # Game Over - if newStats.hp <= 0 - obj.stats.lvl = 0 # signifies dead - obj.stats.hp = 0 - return - else - obj.stats.hp = newStats.hp - - if newStats.exp? - # level up & carry-over exp - tnl = model.get '_tnl' - if newStats.exp >= tnl - newStats.exp -= tnl - obj.stats.lvl++ - obj.stats.hp = 50 - - obj.stats.exp = newStats.exp - - # Set flags when they unlock features - if !obj.flags.customizationsNotification and (obj.stats.exp > 10 or obj.stats.lvl > 1) - batch.set 'flags.customizationsNotification', true - obj.flags.customizationsNotification = true - if !obj.flags.itemsEnabled and obj.stats.lvl >= 2 - # Set to object, then also send to browser right away to get model.on() subscription notification - batch.set 'flags.itemsEnabled', true - obj.flags.itemsEnabled = true - if !obj.flags.partyEnabled and obj.stats.lvl >= 3 - batch.set 'flags.partyEnabled', true - obj.flags.partyEnabled = true - if !obj.flags.petsEnabled and obj.stats.lvl >= 4 - batch.set 'flags.petsEnabled', true - obj.flags.petsEnabled = true - - if newStats.gp? - #FIXME what was I doing here? I can't remember, gp isn't defined - gp = 0.0 if (!gp? or gp<0) - obj.stats.gp = newStats.gp - -# {taskId} task you want to score -# {direction} 'up' or 'down' -# {times} # times to call score on this task (1 unless cron, usually) -# {update} if we're running updates en-mass (eg, cron on server) pass in userObj -score = (taskId, direction, times, batch, cron) -> - - commit = false - unless batch? - commit = true - batch = new character.BatchUpdate(model) - batch.startTransaction() - obj = batch.obj() - - {gp, hp, exp, lvl} = obj.stats - - taskPath = "tasks.#{taskId}" - taskObj = obj.tasks[taskId] - {type, value} = taskObj - - # If they're trying to purhcase a too-expensive reward, confirm they want to take a hit for it - if taskObj.value > obj.stats.gp and taskObj.type is 'reward' - r = confirm "Not enough GP to purchase this reward, buy anyway and lose HP? (Punishment for taking a reward you didn't earn)." - unless r - batch.commit() - return - - delta = 0 - times ?= 1 - calculateDelta = (adjustvalue=true) -> - # If multiple days have passed, multiply times days missed - _.times times, (n) -> - # Each iteration calculate the delta (nextDelta), which is then accumulated in delta - # (aka, the total delta). This weirdness won't be necessary when calculating mathematically - # rather than iteratively - nextDelta = taskDeltaFormula(value, direction) - value += nextDelta if adjustvalue - delta += nextDelta - - addPoints = -> - modified = expModifier(delta) - exp += modified - gp += modified - - subtractPoints = -> - modified = hpModifier(delta) - hp += modified - - switch type - when 'habit' - # Don't adjust values for habits that don't have both + and - - adjustvalue = if (taskObj.up==false or taskObj.down==false) then false else true - calculateDelta(adjustvalue) - # Add habit value to habit-history (if different) - if (delta > 0) then addPoints() else subtractPoints() - taskObj.history ?= [] - if taskObj.value != value - historyEntry = { date: +new Date, value: value } - taskObj.history.push historyEntry - batch.set "#{taskPath}.history", taskObj.history - - when 'daily' - calculateDelta() - if cron? # cron - subtractPoints() - else - addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes - - when 'todo' - calculateDelta() - unless cron? # don't touch stats on cron - addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes - - when 'reward' - # Don't adjust values for rewards - calculateDelta(false) - # purchase item - gp -= Math.abs(taskObj.value) - num = parseFloat(taskObj.value).toFixed(2) - # if too expensive, reduce health & zero gp - if gp < 0 - hp += gp # hp - gp difference - gp = 0 - - taskObj.value = value - batch.set "#{taskPath}.value", taskObj.value - origStats = _.clone obj.stats - updateStats {hp: hp, exp: exp, gp: gp}, batch - if commit - # newStats / origStats is a glorious hack to trick Derby into seeing the change in model.on(*) - newStats = _.clone batch.obj().stats - _.each Object.keys(origStats), (key) -> obj.stats[key] = origStats[key] - batch.setStats(newStats) -# batch.setStats() - batch.commit() - return delta - -### - At end of day, add value to all incomplete Daily & Todo tasks (further incentive) - For incomplete Dailys, deduct experience -### -cron = () -> - today = +new Date - daysPassed = helpers.daysBetween(today, user.get('lastCron')) - if daysPassed > 0 - batch = new character.BatchUpdate(model) - batch.startTransaction() - batch.set 'lastCron', today + ### + Updates user stats with new stats. Handles death, leveling up, etc + {stats} new stats + {update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately + ### + updateStats = (newStats, batch) -> obj = batch.obj() - hpBefore = obj.stats.hp #we'll use this later so we can animate hp loss - # Tally each task - todoTally = 0 - _.each obj.tasks, (taskObj) -> - {id, type, completed, repeat} = taskObj - 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 - daysFailed = 0 - _.times daysPassed, (n) -> - thatDay = moment().subtract('days', n+1) - if repeat[helpers.dayMapping[thatDay.day()]]==true - daysFailed++ - score id, 'down', daysFailed, batch, true - if type == 'daily' - taskObj.history ?= [] - taskObj.history.push { date: +new Date, value: value } - batch.set "tasks.#{taskObj.id}.history", taskObj.history - batch.set "tasks.#{taskObj.id}.completed", false + # if user is dead, dont do anything + return if obj.stats.lvl == 0 + + if newStats.hp? + # Game Over + if newStats.hp <= 0 + obj.stats.lvl = 0 # signifies dead + obj.stats.hp = 0 + return + else + obj.stats.hp = newStats.hp + + if newStats.exp? + # level up & carry-over exp + tnl = model.get '_tnl' + if newStats.exp >= tnl + newStats.exp -= tnl + obj.stats.lvl++ + obj.stats.hp = 50 + + obj.stats.exp = newStats.exp + + # Set flags when they unlock features + if !obj.flags.customizationsNotification and (obj.stats.exp > 10 or obj.stats.lvl > 1) + batch.set 'flags.customizationsNotification', true + obj.flags.customizationsNotification = true + if !obj.flags.itemsEnabled and obj.stats.lvl >= 2 + # Set to object, then also send to browser right away to get model.on() subscription notification + batch.set 'flags.itemsEnabled', true + obj.flags.itemsEnabled = true + if !obj.flags.partyEnabled and obj.stats.lvl >= 3 + batch.set 'flags.partyEnabled', true + obj.flags.partyEnabled = true + if !obj.flags.petsEnabled and obj.stats.lvl >= 4 + batch.set 'flags.petsEnabled', true + obj.flags.petsEnabled = true + + if newStats.gp? + #FIXME what was I doing here? I can't remember, gp isn't defined + gp = 0.0 if (!gp? or gp<0) + obj.stats.gp = newStats.gp + + # {taskId} task you want to score + # {direction} 'up' or 'down' + # {times} # times to call score on this task (1 unless cron, usually) + # {update} if we're running updates en-mass (eg, cron on server) pass in userObj + score = (taskId, direction, times, batch, cron) -> + + commit = false + unless batch? + commit = true + batch = new character.BatchUpdate(model) + batch.startTransaction() + obj = batch.obj() + + {gp, hp, exp, lvl} = obj.stats + + taskPath = "tasks.#{taskId}" + taskObj = obj.tasks[taskId] + {type, value} = taskObj + + # If they're trying to purhcase a too-expensive reward, confirm they want to take a hit for it + if taskObj.value > obj.stats.gp and taskObj.type is 'reward' + r = confirm "Not enough GP to purchase this reward, buy anyway and lose HP? (Punishment for taking a reward you didn't earn)." + unless r + batch.commit() + return + + delta = 0 + times ?= 1 + calculateDelta = (adjustvalue=true) -> + # If multiple days have passed, multiply times days missed + _.times times, (n) -> + # Each iteration calculate the delta (nextDelta), which is then accumulated in delta + # (aka, the total delta). This weirdness won't be necessary when calculating mathematically + # rather than iteratively + nextDelta = taskDeltaFormula(value, direction) + value += nextDelta if adjustvalue + delta += nextDelta + + addPoints = -> + modified = expModifier(delta) + exp += modified + gp += modified + + subtractPoints = -> + modified = hpModifier(delta) + hp += modified + + switch type + when 'habit' + # Don't adjust values for habits that don't have both + and - + adjustvalue = if (taskObj.up==false or taskObj.down==false) then false else true + calculateDelta(adjustvalue) + # Add habit value to habit-history (if different) + if (delta > 0) then addPoints() else subtractPoints() + taskObj.history ?= [] + if taskObj.value != value + historyEntry = { date: +new Date, value: value } + taskObj.history.push historyEntry + batch.set "#{taskPath}.history", taskObj.history + + when 'daily' + calculateDelta() + if cron? # cron + subtractPoints() else - value = obj.tasks[taskObj.id].value #get updated value - absVal = if (completed) then Math.abs(value) else value - todoTally += absVal + addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes - # Finished tallying - obj.history ?= {}; obj.history.todos ?= []; obj.history.exp ?= [] - obj.history.todos.push { date: today, value: todoTally } - # tally experience - expTally = obj.stats.exp - lvl = 0 #iterator - while lvl < (obj.stats.lvl-1) - lvl++ - expTally += (lvl*100)/5 - obj.history.exp.push { date: today, value: expTally } + when 'todo' + calculateDelta() + unless cron? # don't touch stats on cron + addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes - # Set the new user specs, and animate HP loss - [hpAfter, obj.stats.hp] = [obj.stats.hp, hpBefore] - batch.setStats() - batch.set('history', obj.history) - batch.commit() - browser.resetDom(model) - setTimeout (-> user.set 'stats.hp', hpAfter), 1000 # animate hp loss + when 'reward' + # Don't adjust values for rewards + calculateDelta(false) + # purchase item + gp -= Math.abs(taskObj.value) + num = parseFloat(taskObj.value).toFixed(2) + # if too expensive, reduce health & zero gp + if gp < 0 + hp += gp # hp - gp difference + gp = 0 + + taskObj.value = value + batch.set "#{taskPath}.value", taskObj.value + origStats = _.clone obj.stats + updateStats {hp: hp, exp: exp, gp: gp}, batch + if commit + # newStats / origStats is a glorious hack to trick Derby into seeing the change in model.on(*) + newStats = _.clone batch.obj().stats + _.each Object.keys(origStats), (key) -> obj.stats[key] = origStats[key] + batch.setStats(newStats) + # batch.setStats() + batch.commit() + return delta + + ### + At end of day, add value to all incomplete Daily & Todo tasks (further incentive) + For incomplete Dailys, deduct experience + ### + cron = () -> + today = +new Date + daysPassed = helpers.daysBetween(today, user.get('lastCron')) + if daysPassed > 0 + batch = new character.BatchUpdate(model) + batch.startTransaction() + batch.set 'lastCron', today + obj = batch.obj() + hpBefore = obj.stats.hp #we'll use this later so we can animate hp loss + # Tally each task + todoTally = 0 + _.each obj.tasks, (taskObj) -> + {id, type, completed, repeat} = taskObj + 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 + daysFailed = 0 + _.times daysPassed, (n) -> + thatDay = moment().subtract('days', n+1) + if repeat[helpers.dayMapping[thatDay.day()]]==true + daysFailed++ + score id, 'down', daysFailed, batch, true + + if type == 'daily' + taskObj.history ?= [] + taskObj.history.push { date: +new Date, value: value } + batch.set "tasks.#{taskObj.id}.history", taskObj.history + batch.set "tasks.#{taskObj.id}.completed", false + else + value = obj.tasks[taskObj.id].value #get updated value + absVal = if (completed) then Math.abs(value) else value + todoTally += absVal + + # Finished tallying + obj.history ?= {}; obj.history.todos ?= []; obj.history.exp ?= [] + obj.history.todos.push { date: today, value: todoTally } + # tally experience + expTally = obj.stats.exp + lvl = 0 #iterator + while lvl < (obj.stats.lvl-1) + lvl++ + expTally += (lvl*100)/5 + obj.history.exp.push { date: today, value: expTally } + + # Set the new user specs, and animate HP loss + [hpAfter, obj.stats.hp] = [obj.stats.hp, hpBefore] + batch.setStats() + batch.set('history', obj.history) + batch.commit() + browser.resetDom(model) + setTimeout (-> user.set 'stats.hp', hpAfter), 1000 # animate hp loss -module.exports = { - setModel: setModel - MODIFIER: MODIFIER - score: score - cron: cron + return { + MODIFIER: MODIFIER + score: score + cron: cron - # testing stuff - expModifier: expModifier - hpModifier: hpModifier - taskDeltaFormula: taskDeltaFormula -} + # testing stuff + expModifier: expModifier + hpModifier: hpModifier + taskDeltaFormula: taskDeltaFormula + } diff --git a/src/app/tasks.coffee b/src/app/tasks.coffee index 8f14840e9f..22995d868c 100644 --- a/src/app/tasks.coffee +++ b/src/app/tasks.coffee @@ -26,6 +26,7 @@ module.exports.view = (view) -> module.exports.app = (appExports, model) -> user = model.at('_user') + score = new scoring.Scoring(model) user.on 'set', 'tasks.*.completed', (i, completed, previous, isLocal, passed) -> return if passed? && passed.cron # Don't do this stuff on cron @@ -36,7 +37,7 @@ module.exports.app = (appExports, model) -> # Score the user based on todo task task = user.at("tasks.#{i}") - scoring.score(i, direction()) + score.score(i, direction()) appExports.addTask = (e, el, next) -> type = $(el).attr('data-task-type') @@ -81,7 +82,7 @@ module.exports.app = (appExports, model) -> return # Cancel. Don't delete, don't hurt user else task.set('type','habit') # hack to make sure it hits HP, instead of performing "undo checkbox" - scoring.score(id, direction:'down') + score.score(id, direction:'down') # prevent accidently deleting long-standing tasks else @@ -171,4 +172,4 @@ module.exports.app = (appExports, model) -> direction = 'up' if direction == 'true/' direction = 'down' if direction == 'false/' task = model.at $(el).parents('li')[0] - scoring.score(task.get('id'), direction) + score.score(task.get('id'), direction) diff --git a/src/server/api.coffee b/src/server/api.coffee index 043dfc37d8..ef18e8d233 100644 --- a/src/server/api.coffee +++ b/src/server/api.coffee @@ -48,8 +48,8 @@ router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> down: true notes: "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task." - scoring.setModel(model) - delta = scoring.score(taskId, direction) + score = scoring.Scoring(model) + delta = score.score(taskId, direction) result = model.get ('_user.stats') result.delta = delta res.send(result)