diff --git a/.gitignore b/.gitignore index f54c5f2a27..01f730f678 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ node_modules #lib/ *.swp .idea* -config.json \ No newline at end of file +config.json +npm-debug.log \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 5d6f25d3b3..d0d9ca48c5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "public/vendor/BrowserQuest"] path = public/vendor/BrowserQuest url = https://github.com/mozilla/BrowserQuest.git +[submodule "public/vendor/bootstrap-tour"] + path = public/vendor/bootstrap-tour + url = git://github.com/sorich87/bootstrap-tour.git diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..5f1508613a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +3/3/2013 + * Add custom day start: https://trello.com/card/custom-day-start/50e5d3684fe3a7266b0036d6/15 \ No newline at end of file diff --git a/README.md b/README.md index 28ca6ebcce..10aeab7503 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,11 @@ #[HabitRPG](http://habitrpg.com/) -[HabitRPG](http://habitrpg.com/) is a habit tracker app which treats your goals like a Role Playing Game. Level up as you succeed, lose HP as you fail, earn money to buy weapons and armor. +HabitRPG is a habit building program which treats your life like a Role Playing Game. Level up as you succeed, lose HP as you fail, earn money to buy weapons and armor. + +[Read more](https://habitrpg.com/static/about) ![Screenshot](https://raw.github.com/lefnire/habitrpg/master/public/img/screenshot.jpeg "Screenshot") -###[About](https://habitrpg.com/splash.html) -###[Running Locally](https://github.com/lefnire/habitrpg/wiki/Running-Locally) -###[API](https://github.com/lefnire/habitrpg/wiki/API) - -##Contact -###[Bugs](https://github.com/lefnire/habitrpg/issues) -###[New Features](https://trello.com/board/habitrpg/50e5d3684fe3a7266b0036d6) -###[Email](mailto:tylerrenelle@gmail.com) - ##License Code is licensed under GNU GPL v3. Content is licensed under CC-BY-SA 3.0. See the LICENSE file for details. diff --git a/migrations/20130307_exp_overflow.js b/migrations/20130307_exp_overflow.js new file mode 100644 index 0000000000..44fb2904b4 --- /dev/null +++ b/migrations/20130307_exp_overflow.js @@ -0,0 +1,39 @@ +// mongo habitrpg ./node_modules/underscore/underscore.js ./migrations/20130307_normalize_algo_values.js + +/** + * Make sure people aren't overflowing their exp with the new system + */ +db.users.find().forEach(function(user){ + function oldTnl(level) { + return (Math.pow(level,2)*10)+(level*10)+80 + } + + function newTnl(level) { + var value = 0; + if (level >= 100) { + value = 0 + } else { + value = Math.round(((Math.pow(level,2)*0.25)+(10 * level) + 139.75)/10)*10; // round to nearest 10 + } + return value + } + + var newTnl = newTnl(user.stats.lvl); + if (user.stats.exp > newTnl) { + var percent = user.stats.exp / oldTnl(user.stats.lvl); + percent = (percent>1) ? 1 : percent; + user.stats.exp = newTnl * percent; + + try { + db.users.update( + {_id:user._id}, + {$set: {'stats.exp': user.stats.exp}}, + {multi:true} + ); + } catch(e) { + print(e); + } + + } + +}) \ No newline at end of file diff --git a/migrations/20130307_normalize_algo_values.js b/migrations/20130307_normalize_algo_values.js new file mode 100644 index 0000000000..e42faded43 --- /dev/null +++ b/migrations/20130307_normalize_algo_values.js @@ -0,0 +1,47 @@ +// mongo habitrpg ./node_modules/underscore/underscore.js ./migrations/20130307_normalize_algo_values.js + +/** + * Users were experiencing a lot of extreme Exp multiplication (https://github.com/lefnire/habitrpg/issues/594). + * This sets things straight, and in preparation for another algorithm overhaul + */ +db.users.find().forEach(function(user){ + if (user.stats.exp >= 3580) { + user.stats.exp = 0; + } + + if (user.stats.lvl > 100) { + user.stats.lvl = 100; + } + + _.each(user.tasks, function(task, key){ + // remove corrupt tasks + if (!task) { + delete user.tasks[key]; + return; + } + + // Fix busted values + if (task.value > 21.27) { + task.value = 21.27; + } + else if (task.value < -47.27) { + task.value = -47.27; + } + }); + + try { + db.users.update( + {_id:user._id}, + {$set: + { + 'stats.lvl': user.stats.lvl, + 'stats.exp': user.stats.exp, + 'tasks' : user.tasks + } + }, + {multi:true} + ); + } catch(e) { + print(e); + } +}) \ No newline at end of file diff --git a/migrations/20130307_remove_duff_histories.js b/migrations/20130307_remove_duff_histories.js new file mode 100644 index 0000000000..6211693a1f --- /dev/null +++ b/migrations/20130307_remove_duff_histories.js @@ -0,0 +1,28 @@ +/** + * Remove duff histories for dailies + */ +// mongo habitrpg ./node_modules/underscore/underscore.js ./migrations/20130307_remove_duff_histories.js +db.users.find().forEach(function(user){ + + + _.each(user.tasks, function(task, key){ + if (task.type === "daily") { + // remove busted history entries + task.history = _.filter(task.history, function(h){return !!h.value}) + } + }); + + try { + db.users.update( + {_id:user._id}, + {$set: + { + 'tasks' : user.tasks + } + }, + {multi:true} + ); + } catch(e) { + print(e); + } +}) \ No newline at end of file diff --git a/migrations/find_unique_user.js b/migrations/find_unique_user.js new file mode 100644 index 0000000000..c72de09f36 --- /dev/null +++ b/migrations/find_unique_user.js @@ -0,0 +1,11 @@ +// mongo habitrpg ./node_modules/underscore/underscore.js ./migrations/find_unique_user.js + +/** + * There are some rare instances of lost user accounts, due to a corrupt user auth variable (see https://github.com/lefnire/habitrpg/wiki/User-ID) + * Past in the text of a unique habit here to find the user, then you can restore their UUID + */ + +db.users.find().forEach(function(user){ + var found = _.findWhere(user.tasks, {text: "Replace Me"}) + if (found) printjson({id:user._id, auth:user.auth}); +}) \ No newline at end of file diff --git a/package.json b/package.json index 258bab8e95..a185c5fd20 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0-150", "main": "./server.js", "dependencies": { - "derby": "git://github.com/lefnire/derby#habitrpg", + "derby": "git://github.com/Unroll-Me/derby#master", "racer": "git://github.com/lefnire/racer#habitrpg", "racer-db-mongo": "git://github.com/lefnire/racer-db-mongo#habitrpg", "derby-ui-boot": "git://github.com/codeparty/derby-ui-boot#master", @@ -23,9 +23,10 @@ "mongoskin": "*", "nconf": "*", "icalendar": "git://github.com/lefnire/node-icalendar#master", + "superagent": "~0.12.4", "resolve": "~0.2.3", "browserify": "1.17.3", - "webkit-devtools-agent": "*" + "expect.js": "~0.2.0" }, "private": true, "subdomain": "habitrpg", @@ -38,6 +39,7 @@ "npm": "1.1.x" }, "scripts": { - "start": "server.js" + "start": "server.js", + "test": "mocha test/api.mocha.coffee" } } diff --git a/public/500.html b/public/500.html index 60b33284a4..9d4d7270d1 100644 --- a/public/500.html +++ b/public/500.html @@ -1,27 +1,17 @@ - HabitRPG | Gamify Your Life + HabitRPG | Error - - - + + + + + - - - -
-
-

HabitRPG

-

Habit tracking which treats your goals like a Role Playing Game. Level up as you succeed, lose HP as you fail, earn money to buy weapons and armor.

- Play -
-
-
-
- -
-
-
-
-

 

-

-
- -
- -

Habits are goals that you constantly track. For some habits, it only makes sense to gain points (eg, "1h Productive Work"). For others, it only makes sense to lose points (like "Eat Junk Food"). For the rest, both gain and loss apply (eg, for "Take The Stairs", stairs is a gain, elevator is a loss).

-
- -
- -

Dailies are goals that you want to complete once a day. At the end of each day, non-completed Dailies dock you points. If you are doing well, they turn green and are less valuable (experience, gold) and less damaging (HP). This means you can ease up on them for a bit. But if you are doing poorly, they turn red. The worse you do, the more valuable (exp, gold) and more damaging (HP) these goals become. This encourages you to focus on your shortcomings, the reds.

-
- -
- -

Todos are one-off goals which need to be completed eventually. Non-completed Todos won’t hurt you, but they will become more valuable over time. This will encourage you to wrap up stale Todos.

-
- -
- -

As you complete goals, you earn gold to buy rewards. Buy them liberally - rewards are integral in forming good habits. But only buy if you have enough gold - you lose HP otherwise.

-

After you’ve played for a while, you unlock the Item Store under the rewards column. You can now buy weapons, armor, potions, etc. Armor decreases HP loss (by an increasing amount wich each upgrade). Weapons increase experience gain. Potions recover 15 HP.

-
- -
- -

Code is licensed under GNU GPL v3. Content is licensed under CC-BY-SA 3.0. See the LICENSE file for details.

- -

Content and assets comes from Mozilla’s BrowserQuest (Mozilla, Little Workshop). They seriously rock. -

-
- -
- - diff --git a/public/vendor/bootstrap b/public/vendor/bootstrap index 8c7f9c66a7..eb24718add 160000 --- a/public/vendor/bootstrap +++ b/public/vendor/bootstrap @@ -1 +1 @@ -Subproject commit 8c7f9c66a7d12f47f50618ef420868fe836d0c33 +Subproject commit eb24718add4dd36fe92fdbdb79e6ff4ce5919300 diff --git a/public/vendor/bootstrap-tour b/public/vendor/bootstrap-tour new file mode 160000 index 0000000000..4f94fa056c --- /dev/null +++ b/public/vendor/bootstrap-tour @@ -0,0 +1 @@ +Subproject commit 4f94fa056c88c6099dea135b644d8f95d38ac9e1 diff --git a/public/vendor/bootstrap-tour.js b/public/vendor/bootstrap-tour.js deleted file mode 100644 index 3ed1b2bbf9..0000000000 --- a/public/vendor/bootstrap-tour.js +++ /dev/null @@ -1,271 +0,0 @@ - -/* ============================================================ - # bootstrap-tour.js v0.1 - # http://pushly.github.com/bootstrap-tour/ - # ============================================================== - # Copyright 2012 Push.ly - # - # Licensed under the Apache License, Version 2.0 (the "License"); - # you may not use this file except in compliance with the License. - # You may obtain a copy of the License at - # - # http://www.apache.org/licenses/LICENSE-2.0 - # - # Unless required by applicable law or agreed to in writing, software - # distributed under the License is distributed on an "AS IS" BASIS, - # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - # See the License for the specific language governing permissions and - # limitations under the License. - */ - -(function() { - - (function($, window) { - var Tour, document; - document = window.document; - Tour = (function() { - - function Tour(options) { - var _this = this; - this._options = $.extend({ - name: 'tour', - labels: { - end: 'End tour', - next: 'Next »', - prev: '« Prev' - }, - keyboard: true, - afterSetState: function(key, value) {}, - afterGetState: function(key, value) {}, - onShow: function(tour) {}, - onHide: function(tour) {}, - onShown: function(tour) {} - }, options); - this._steps = []; - this.setCurrentStep(); - this._onresize(function() { - if (!_this.ended) return _this.showStep(_this._current); - }); - } - - Tour.prototype.setState = function(key, value) { - $.cookie("" + this._options.name + "_" + key, value, { - expires: 36500, - path: '/' - }); - return this._options.afterSetState(key, value); - }; - - Tour.prototype.getState = function(key) { - var value; - value = $.cookie("" + this._options.name + "_" + key); - this._options.afterGetState(key, value); - return value; - }; - - Tour.prototype.addStep = function(step) { - return this._steps.push(step); - }; - - Tour.prototype.getStep = function(i) { - if (this._steps[i] != null) { - return $.extend({ - path: "", - placement: "right", - title: "", - content: "", - next: i === this._steps.length - 1 ? -1 : i + 1, - prev: i - 1, - animation: true, - onShow: this._options.onShow, - onHide: this._options.onHide, - onShown: this._options.onShown - }, this._steps[i]); - } - }; - - Tour.prototype.start = function(force) { - var _this = this; - if (force == null) force = false; - if (this.ended() && !force) return; - $(document).off("click.bootstrap-tour", ".popover .next").on("click.bootstrap-tour", ".popover .next", function(e) { - e.preventDefault(); - return _this.next(); - }); - $(document).off("click.bootstrap-tour", ".popover .prev").on("click.bootstrap-tour", ".popover .prev", function(e) { - e.preventDefault(); - return _this.prev(); - }); - $(document).off("click.bootstrap-tour", ".popover .end").on("click.bootstrap-tour", ".popover .end", function(e) { - e.preventDefault(); - return _this.end(); - }); - this._setupKeyboardNavigation(); - return this.showStep(this._current); - }; - - Tour.prototype.next = function() { - this.hideStep(this._current); - return this.showNextStep(); - }; - - Tour.prototype.prev = function() { - this.hideStep(this._current); - return this.showPrevStep(); - }; - - Tour.prototype.end = function() { - this.hideStep(this._current); - $(document).off(".bootstrap-tour"); - return this.setState("end", "yes"); - }; - - Tour.prototype.ended = function() { - return !!this.getState("end"); - }; - - Tour.prototype.restart = function() { - this.setState("current_step", null); - this.setState("end", null); - this.setCurrentStep(0); - return this.start(); - }; - - Tour.prototype.hideStep = function(i) { - var step; - step = this.getStep(i); - if (step.onHide != null) step.onHide(this); - return $(step.element).popover("hide"); - }; - - Tour.prototype.showStep = function(i) { - var step; - step = this.getStep(i); - if (!step) return; - this.setCurrentStep(i); - if (step.path !== "" && document.location.pathname !== step.path && document.location.pathname.replace(/^.*[\\\/]/, '') !== step.path) { - document.location.href = step.path; - return; - } - if (step.onShow != null) step.onShow(this); - if (!((step.element != null) && $(step.element).length !== 0 && $(step.element).is(":visible"))) { - this.showNextStep(); - return; - } - this._showPopover(step, i); - if (step.onShown != null) return step.onShown(this); - }; - - Tour.prototype.setCurrentStep = function(value) { - if (value != null) { - this._current = value; - return this.setState("current_step", value); - } else { - this._current = this.getState("current_step"); - if (this._current === null || this._current === "null") { - return this._current = 0; - } else { - return this._current = parseInt(this._current); - } - } - }; - - Tour.prototype.showNextStep = function() { - var step; - step = this.getStep(this._current); - return this.showStep(step.next); - }; - - Tour.prototype.showPrevStep = function() { - var step; - step = this.getStep(this._current); - return this.showStep(step.prev); - }; - - Tour.prototype._showPopover = function(step, i) { - var content, nav, options, tip, - _this = this; - content = "" + step.content + "

"; - options = $.extend({}, this._options); - if (step.options) $.extend(options, step.options); - if (step.reflex) { - $(step.element).css("cursor", "pointer"); - $(step.element).on("click", function(e) { - $(step.element).css("cursor", "auto"); - return _this.next(); - }); - } - nav = []; - if (step.prev >= 0) { - nav.push(""); - } - if (step.next >= 0) { - nav.push(""); - } - content += nav.join(" | "); - content += "" + options.labels.end + ""; - $(step.element).popover({ - placement: step.placement, - trigger: "manual", - title: step.title, - content: content, - html: true, - animation: step.animation - }).popover("show"); - tip = $(step.element).data("popover").tip(); - this._reposition(tip); - return this._scrollIntoView(tip); - }; - - Tour.prototype._reposition = function(tip) { - var offsetBottom, offsetRight, tipOffset; - tipOffset = tip.offset(); - offsetBottom = $(document).outerHeight() - tipOffset.top - $(tip).outerHeight(); - if (offsetBottom < 0) tipOffset.top = tipOffset.top + offsetBottom; - offsetRight = $(document).outerWidth() - tipOffset.left - $(tip).outerWidth(); - if (offsetRight < 0) tipOffset.left = tipOffset.left + offsetRight; - if (tipOffset.top < 0) tipOffset.top = 0; - if (tipOffset.left < 0) tipOffset.left = 0; - return tip.offset(tipOffset); - }; - - Tour.prototype._scrollIntoView = function(tip) { - var tipRect; - tipRect = tip.get(0).getBoundingClientRect(); - if (!(tipRect.top > 0 && tipRect.bottom < $(window).height() && tipRect.left > 0 && tipRect.right < $(window).width())) { - return tip.get(0).scrollIntoView(true); - } - }; - - Tour.prototype._onresize = function(cb, timeout) { - return $(window).resize(function() { - clearTimeout(timeout); - return timeout = setTimeout(cb, 100); - }); - }; - - Tour.prototype._setupKeyboardNavigation = function() { - var _this = this; - if (this._options.keyboard) { - return $(document).on("keyup.bootstrap-tour", function(e) { - if (!e.which) return; - switch (e.which) { - case 39: - e.preventDefault(); - if (_this._current < _this._steps.length - 1) return _this.next(); - break; - case 37: - e.preventDefault(); - if (_this._current > 0) return _this.prev(); - } - }); - } - }; - - return Tour; - - })(); - return window.Tour = Tour; - })(jQuery, window); - -}).call(this); \ No newline at end of file diff --git a/server.js b/server.js index 51db9480cf..6412b78225 100644 --- a/server.js +++ b/server.js @@ -22,7 +22,7 @@ process.env.SMTP_SERVICE = conf.get("SMTP_SERVICE"); process.env.STRIPE_API_KEY = conf.get("STRIPE_API_KEY"); process.env.STRIPE_PUB_KEY = conf.get("STRIPE_PUB_KEY"); -var agent; +/*var agent; if (process.env.NODE_ENV === 'development') { // Follow these instructions for profiling / debugging leaks // * https://developers.google.com/chrome-developer-tools/docs/heap-profiling @@ -31,7 +31,7 @@ if (process.env.NODE_ENV === 'development') { console.log("To debug memory leaks:" + "\n\t(1) Run `kill -SIGUSR2 " + process.pid + "`" + "\n\t(2) open http://c4milo.github.com/node-webkit-agent/21.0.1180.57/inspector.html?host=localhost:1337&page=0"); -} +}*/ process.on('uncaughtException', function (error) { diff --git a/src/app/algos.coffee b/src/app/algos.coffee new file mode 100644 index 0000000000..fff6cf5a7a --- /dev/null +++ b/src/app/algos.coffee @@ -0,0 +1,67 @@ +XP = 15 +HP = 2 + +priorityValue = (priority='!') -> + switch priority + when '!' then 1 + when '!!' then 1.5 + when '!!!' then 2 + else 1 + +module.exports.tnl = (level) -> + if level >= 100 + value = 0 + else + value = Math.round(((Math.pow(level,2)*0.25)+(10 * level) + 139.75)/10)*10 # round to nearest 10 + return value + +### + Calculates Exp modificaiton based on level and weapon strength + {value} task.value for exp gain + {weaponStrength) weapon strength + {level} current user level + {priority} user-defined priority multiplier +### +module.exports.expModifier = (value, weaponStr, level, priority='!') -> + str = (level-1) / 2 # ultimately get this from user + totalStr = (str + weaponStr) / 100 + strMod = 1 + totalStr + exp = value * XP * strMod * priorityValue(priority) + return Math.round(exp) + +### + Calculates HP modification based on level and armor defence + {value} task.value for hp loss + {armorDefense} defense from armor + {helmDefense} defense from helm + {level} current user level + {priority} user-defined priority multiplier +### +module.exports.hpModifier = (value, armorDef, helmDef, shieldDef, level, priority='!') -> + def = (level-1) / 2 # ultimately get this from user? + totalDef = (def + armorDef + helmDef + shieldDef) / 100 #ultimate get this from user + defMod = 1 - totalDef + hp = value * HP * defMod * priorityValue(priority) + return Math.round(hp * 10)/10 # round to 1dp + +### + Future use + {priority} user-defined priority multiplier +### +module.exports.gpModifier = (value, modifier, priority='!') -> + return value * modifier * priorityValue(priority) + +### + Calculates the next task.value based on direction + Uses a capped inverse log y=.95^x, y>= -5 + {currentValue} the current value of the task + {direction} up or down +### +module.exports.taskDeltaFormula = (currentValue, direction) -> + if currentValue < -47.27 then currentValue = -47.27 + else if currentValue > 21.27 then currentValue = 21.27 + delta = Math.pow(0.9747,currentValue) + return delta if direction is 'up' + return -delta + + diff --git a/src/app/browser.coffee b/src/app/browser.coffee index 2830fefd48..6042821d90 100644 --- a/src/app/browser.coffee +++ b/src/app/browser.coffee @@ -1,75 +1,36 @@ _ = require 'underscore' moment = require 'moment' +#algos = require './algos' -module.exports.restoreRefs = restoreRefs = (model) -> - # tnl function - model.fn '_tnl', '_user.stats.lvl', (lvl) -> - # see https://github.com/lefnire/habitrpg/issues/4 - # also update in scoring.coffee. TODO create a function accessible in both locations - (lvl*100)/5 +restoreRefs = module.exports.restoreRefs = (model) -> #refLists _.each ['habit', 'daily', 'todo', 'reward'], (type) -> model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" -module.exports.resetDom = (model) -> - window.DERBY.app.dom.clear() - restoreRefs(model) - window.DERBY.app.view.render(model) - reconstructPage model - -module.exports.app = (appExports, model) -> - reconstructPage model - -reconstructPage = (model) -> - loadJavaScripts(model) - setupSortable(model) - setupTooltips(model) - setupTour(model) - setupGrowlNotifications(model) unless model.get('_view.mobileDevice') - $('.datepicker').datepicker({autoclose:true, todayBtn:true}) - .on 'changeDate', (ev) -> - #for some reason selecting a date doesn't fire a change event on the field, meaning our changes aren't saved - #FIXME also, it saves as a day behind?? - model.at(ev.target).set 'date', moment(ev.date).add('d',1).format('MM/DD/YYYY') - ### Loads JavaScript files from (1) public/js/* and (2) external sources - We use this file (instead of or inside .html) so we can utilize require() to concatinate for - faster page load, and $.getScript for asyncronous external script loading + If a library is available in a CDN, we put it in (index.html) for better caching. If not, we use + this function to utilize require() to concatinate for faster page load, and $.getScript for asyncronous external script loading ### loadJavaScripts = (model) -> - require '../../public/vendor/jquery-ui/jquery-1.9.1' unless model.get('_view.mobileDevice') require '../../public/vendor/jquery-ui/ui/jquery.ui.core' require '../../public/vendor/jquery-ui/ui/jquery.ui.widget' require '../../public/vendor/jquery-ui/ui/jquery.ui.mouse' require '../../public/vendor/jquery-ui/ui/jquery.ui.sortable' - # Bootstrap - require '../../public/vendor/bootstrap/docs/assets/js/bootstrap' -# require '../../public/vendor/bootstrap/js/bootstrap-tooltip' -# require '../../public/vendor/bootstrap/js/bootstrap-tab' -# require '../../public/vendor/bootstrap/js/bootstrap-popover' -# require '../../public/vendor/bootstrap/js/bootstrap-modal' -# require '../../public/vendor/bootstrap/js/bootstrap-dropdown' - - - require '../../public/vendor/jquery-cookie/jquery.cookie' - require '../../public/vendor/bootstrap-tour' #https://raw.github.com/pushly/bootstrap-tour/master/bootstrap-tour.js - require '../../public/vendor/bootstrap-datepicker/js/bootstrap-datepicker' - require '../../public/vendor/bootstrap-growl/jquery.bootstrap-growl.min' - + require '../../public/vendor/bootstrap-tour/bootstrap-tour' # JS files not needed right away (google charts) or entirely optional (analytics) # Each file getsload asyncronously via $.getScript, so it doesn't bog page-load unless model.get('_view.mobileDevice') - $.getScript("https://s7.addthis.com/js/250/addthis_widget.js#pubid=lefnire"); + $.getScript("//s7.addthis.com/js/250/addthis_widget.js#pubid=lefnire"); # Google Charts - $.getScript "https://www.google.com/jsapi", -> + $.getScript "//www.google.com/jsapi", -> # Specifying callback in options param is vital! Otherwise you get blank screen, see http://stackoverflow.com/a/12200566/362790 google.load "visualization", "1", {packages:["corechart"], callback: ->} @@ -81,15 +42,11 @@ loadJavaScripts = (model) -> ### setupSortable = (model) -> unless (model.get('_view.mobileDevice') == true) #don't do sortable on mobile - # Make the lists draggable using jQuery UI - # Note, have to setup helper function here and call it for each type later - # due to variable binding of "type" - setupSortable = (type) -> + _.each ['habit', 'daily', 'todo', 'reward'], (type) -> $("ul.#{type}s").sortable dropOnEmpty: false cursor: "move" items: "li" - opacity: 0.4 scroll: true axis: 'y' update: (e, ui) -> @@ -102,16 +59,15 @@ setupSortable = (model) -> # Also, note that refList index arguments can either be an index # or the item's id property model.at("_#{type}List").pass(ignore: domId).move {id}, to - _.each ['habit', 'daily', 'todo', 'reward'], (type) -> setupSortable(type) setupTooltips = (model) -> $('[rel=tooltip]').tooltip() $('[rel=popover]').popover() - # FIXME: this isn't very efficient, do model.on set for specific attrs for popover - model.on 'set', '*', -> - $('[rel=tooltip]').tooltip() - $('[rel=popover]').popover() + $('.priority-multiplier-help').popover + title: "How difficult is this task?" + trigger: "hover" + content: "This multiplies its point value. Use sparingly, rely instead on our organic value-adjustment algorithms. But some tasks are grossly more valuable (Write Thesis vs Floss Teeth). Click for more info." setupTour = (model) -> tourSteps = [ @@ -160,12 +116,8 @@ setupTour = (model) -> $('.main-avatar').popover('destroy') #remove previous popovers tour = new Tour() _.each tourSteps, (step) -> - tour.addStep - html: true - element: step.element - title: step.title - content: step.content - placement: step.placement + tour.addStep _.defaults step, {html:true} + tour._current = 0 if isNaN(tour._current) #bootstrap-tour bug tour.start() ### @@ -180,7 +132,7 @@ setupGrowlNotifications = (model) -> return if user.get('stats.lvl') == 0 $.bootstrapGrowl html, ele: '#notification-area', - type: type # (null, 'info', 'error', 'success', 'gp', 'xp', 'hp', 'lvl') + type: type # (null, 'info', 'error', 'success', 'gp', 'xp', 'hp', 'lvl','death') top_offset: 20 align: 'right' # ('left', 'right', or 'center') width: 250 # (integer, or 'auto') @@ -193,21 +145,55 @@ setupGrowlNotifications = (model) -> num = captures - args rounded = Math.abs(num.toFixed(1)) if num < 0 - statsNotification " -#{rounded} HP", 'hp' # lost hp from purchase + statsNotification " - #{rounded} HP", 'hp' # lost hp from purchase + else if num > 0 + statsNotification " + #{rounded} HP", 'hp' # gained hp from potion/level? + + user.on 'set', 'stats.exp', (captures, args, isLocal, silent=false) -> + # unless silent + num = captures - args + rounded = Math.abs(num.toFixed(1)) + if num < 0 and num > -50 # TODO fix hackey negative notification supress + statsNotification " - #{rounded} XP", 'xp' + else if num > 0 + statsNotification " + #{rounded} XP", 'xp' user.on 'set', 'stats.gp', (captures, args) -> num = captures - args - rounded = Math.abs(num.toFixed(1)) - # made purchase - if num < 0 - # FIXME use 'warning' when unchecking an accidently completed daily/todo, and notify of exp too - statsNotification " -#{rounded} GP", 'gp' - # gained gp (and thereby exp) - else if num > 0 - num = Math.abs(num) - statsNotification " +#{rounded} XP", 'xp' - statsNotification " +#{rounded} GP", 'gp' + absolute = Math.abs(num) + gold = Math.floor(absolute) + silver = Math.floor((absolute-gold)*100) + sign = if num < 0 then '-' else '+' + if gold and silver > 0 + statsNotification "#{sign} #{gold} #{silver} ", 'gp' + else if gold > 0 + statsNotification "#{sign} #{gold} ", 'gp' + else if silver > 0 + statsNotification "#{sign} #{silver} ", 'gp' user.on 'set', 'stats.lvl', (captures, args) -> if captures > args - statsNotification(' Level Up!', 'lvl') \ No newline at end of file + if captures is 1 and args is 0 + statsNotification ' You died! Game over.', 'death' + else + statsNotification ' Level Up!', 'lvl' + + +module.exports.resetDom = (model) -> + DERBY.app.dom.clear() + DERBY.app.view.render(model, DERBY.app.view._lastRender.ns, DERBY.app.view._lastRender.context); + +module.exports.app = (appExports, model, app) -> + loadJavaScripts(model) + setupGrowlNotifications(model) unless model.get('_view.mobileDevice') + + app.on 'render', (ctx) -> + #restoreRefs(model) + setupSortable(model) + setupTooltips(model) + setupTour(model) + $('.datepicker').datepicker({autoclose:true, todayBtn:true}) + .on 'changeDate', (ev) -> + #for some reason selecting a date doesn't fire a change event on the field, meaning our changes aren't saved + #FIXME also, it saves as a day behind?? + model.at(ev.target).set 'date', moment(ev.date).add('d',1).format('MM/DD/YYYY') \ No newline at end of file diff --git a/src/app/character.coffee b/src/app/character.coffee index 1c3dd6334d..e5abfeda80 100644 --- a/src/app/character.coffee +++ b/src/app/character.coffee @@ -1,6 +1,7 @@ character = require './character' browser = require './browser' items = require './items' +algos = require './algos' moment = require 'moment' _ = require 'underscore' @@ -21,6 +22,9 @@ module.exports.username = username = (auth) -> module.exports.view = (view) -> view.fn "username", (auth) -> username(auth) + view.fn "tnl", algos.tnl + + module.exports.app = (appExports, model) -> user = model.at '_user' @@ -57,8 +61,14 @@ module.exports.app = (appExports, model) -> batch.commit() browser.resetDom(model) - appExports.closeCelebrationNofitication = (e, el) -> - user.set('flags.celebrationEvent', 'hide') + appExports.closeAlgosNotification = (e, el) -> + user.set('flags.algosNotification', 'hide') + + appExports.closeOnliesNotification = (e, el) -> + user.set('flags.onliesNotification', 'hide') + + appExports.closePriorityNotification = (e, el) -> + user.set('flags.priorityNotification', 'hide') appExports.customizeGender = (e, el) -> user.set 'preferences.gender', $(el).attr('data-value') @@ -76,9 +86,16 @@ module.exports.app = (appExports, model) -> batch = new BatchUpdate(model) batch.startTransaction() $('#restore-form input').each -> - batch.set $(this).attr('data-for'), parseInt($(this).val()) + batch.set $(this).attr('data-for'), parseInt($(this).val() || 1) batch.commit() + appExports.toggleHeader = (e, el) -> + user.set 'preferences.hideHeader', !user.get('preferences.hideHeader') + + appExports.deleteAccount = (e, el) -> + model.del "users.#{user.get('id')}", -> + window.location.href = "/logout" + user.on 'set', 'flags.customizationsNotification', (captures, args) -> return unless captures == true $('.main-avatar').popover('destroy') #remove previous popovers @@ -99,7 +116,7 @@ userSchema = stats: { gp: 0, exp: 0, lvl: 1, hp: 50 } party: { current: null, invitation: null } items: { weapon: 0, armor: 0, head: 0, shield: 0 } - preferences: { gender: 'm', skin: 'white', hair: 'blond', armorSet: 'v1' } + preferences: { gender: 'm', skin: 'white', hair: 'blond', armorSet: 'v1', dayStart:0, showHelm: true } habitIds: [] dailyIds: [] todoIds: [] @@ -119,14 +136,18 @@ module.exports.newUserObject = -> newUser = lodash.cloneDeep userSchema newUser.apiToken = derby.uuid() + repeat = {m:true,t:true,w:true,th:true,f:true,s:true,su:true} defaultTasks = [ {type: 'habit', text: '1h Productive Work', notes: '-- Habits: Constantly Track --\nFor some habits, it only makes sense to *gain* points (like this one).', value: 0, up: true, down: false } {type: 'habit', text: 'Eat Junk Food', notes: 'For others, it only makes sense to *lose* points', value: 0, up: false, down: true} {type: 'habit', text: 'Take The Stairs', notes: 'For the rest, both + and - make sense (stairs = gain, elevator = lose)', value: 0, up: true, down: true} - {type: 'daily', text: '1h Personal Project', notes: '-- Dailies: Complete Once a Day --\nAt the end of each day, non-completed Dailies dock you points.', value: 0, completed: false } - {type: 'daily', text: 'Exercise', notes: "If you are doing well, they turn green and are less valuable (experience, gold) and less damaging (HP). This means you can ease up on them for a bit.", value: 3, completed: false } - {type: 'daily', text: '45m Reading', notes: 'But if you are doing poorly, they turn red. The worse you do, the more valuable (exp, gold) and more damaging (HP) these goals become. This encourages you to focus on your shortcomings, the reds.', value: -10, completed: false } + + {type: 'daily', text: '1h Personal Project', notes: '-- Dailies: Complete Once a Day --\nAt the end of each day, non-completed Dailies dock you points.', value: 0, completed: false, repeat: repeat } + {type: 'daily', text: 'Exercise', notes: "If you are doing well, they turn green and are less valuable (experience, gold) and less damaging (HP). This means you can ease up on them for a bit.", value: 3, completed: false, repeat: repeat } + {type: 'daily', text: '45m Reading', notes: 'But if you are doing poorly, they turn red. The worse you do, the more valuable (exp, gold) and more damaging (HP) these goals become. This encourages you to focus on your shortcomings, the reds.', value: -10, completed: false, repeat: repeat } + {type: 'todo', text: 'Call Mom', notes: "-- Todos: Complete Eventually --\nNon-completed Todos won't hurt you, but they will become more valuable over time. This will encourage you to wrap up stale Todos.", value: -3, completed: false } + {type: 'reward', text: '1 Episode of Game of Thrones', notes: '-- Rewards: Treat Yourself! --\nAs you complete goals, you earn gold to buy rewards. Buy them liberally - rewards are integral in forming good habits.', value: 20 } {type: 'reward', text: 'Cake', notes: 'But only buy if you have enough gold - you lose HP otherwise.', value: 10 } ] @@ -177,7 +198,7 @@ module.exports.updateUser = (model) -> union = _.union obj[type + 'Ids'], taskIds # 2. remove empty (grey) tasks - preened = _.filter(union, (val) -> _.contains(taskIds, val)) + preened = _.filter union, (val) -> _.contains(taskIds, val) and val? # There were indeed issues found, set the new list batch.set("#{type}Ids", preened) # if _.difference(preened, userObj[path]).length != 0 @@ -233,6 +254,7 @@ module.exports.BatchUpdate = BatchUpdate = (model) -> commit: -> model._dontPersist = false # some hackery in our own branched racer-db-mongo, see findAndModify of lefnire/racer-db-mongo#habitrpg index.js + # pass true if we have levelled to supress xp notification user.set "update__", updates transactionInProgress = false updates = {} diff --git a/src/app/debug.coffee b/src/app/debug.coffee index 25cb833b8f..1017118a9f 100644 --- a/src/app/debug.coffee +++ b/src/app/debug.coffee @@ -1,4 +1,5 @@ moment = require 'moment' +algos = require './algos' module.exports.app = (appExports, model) -> user = model.at('_user') @@ -8,6 +9,20 @@ module.exports.app = (appExports, model) -> user.set 'lastCron', yesterday window.location.reload() + appExports.emulateTenDays = -> + yesterday = +moment().subtract('days', 10).toDate() + user.set 'lastCron', yesterday + window.location.reload() + appExports.cheat = -> - user.incr 'stats.exp', 20 + user.incr 'stats.exp', algos.tnl(user.get('stats.lvl')) user.incr 'stats.gp', 1000 + + appExports.reset = -> + user.set 'stats.exp', 0 + user.set 'stats.lvl', 0 + user.set 'stats.gp', 0 + user.set 'items.weapon', 0 + user.set 'items.armor', 0 + user.set 'items.head', 0 + user.set 'items.shield', 0 \ No newline at end of file diff --git a/src/app/helpers.coffee b/src/app/helpers.coffee index 38733f11d5..363f0b7ada 100644 --- a/src/app/helpers.coffee +++ b/src/app/helpers.coffee @@ -1,8 +1,11 @@ -moment = require('moment') +moment = require 'moment' +_ = require 'underscore' -# Absolute diff between two dates, based on 12am for both days -module.exports.daysBetween = (a, b) -> - Math.abs(moment(a).startOf('day').diff(moment(b).startOf('day'), 'days')) +# Absolute diff between two dates +module.exports.daysBetween = (yesterday, now, dayStart) -> + #sanity-check reset-time (is it 24h time?) + dayStart = 0 unless (dayStart? and (dayStart = parseInt(dayStart)) and dayStart >= 0 and dayStart <= 24) + Math.abs moment(yesterday).startOf('day').add('h', dayStart).diff(moment(now), 'days') module.exports.dayMapping = dayMapping = {0:'su',1:'m',2:'t',3:'w',4:'th',5:'f',6:'s',7:'su'} @@ -14,6 +17,12 @@ module.exports.viewHelpers = (view) -> view.fn "round", (num) -> Math.round num + + view.fn "floor", (num) -> + Math.floor num + + view.fn "ceil", (num) -> + Math.ceil num view.fn "lt", (a, b) -> a < b @@ -29,4 +38,8 @@ module.exports.viewHelpers = (view) -> loc = window?.location.host or process.env.BASE_URL encodeURIComponent "http://#{loc}/v1/users/#{uid}/calendar.ics?apiToken=#{apiToken}" + view.fn "notEqual", (a, b) -> (a != b) + view.fn "and", -> _.reduce arguments, (cumm, curr) -> cumm && curr + view.fn "or", -> _.reduce arguments, (cumm, curr) -> cumm || curr + diff --git a/src/app/index.coffee b/src/app/index.coffee index 49e18c800b..551f711952 100644 --- a/src/app/index.coffee +++ b/src/app/index.coffee @@ -1,6 +1,7 @@ derby = require 'derby' -{get, view, ready} = derby.createApp module -derby.use require('derby-ui-boot'), {styles: ['bootstrap', 'responsive']} +app = derby.createApp module +{get, view, ready} = app +derby.use require('derby-ui-boot'), {styles: []} derby.use require '../../ui' derby.use require 'derby-auth/components' @@ -46,13 +47,12 @@ get '/', (page, model, params, next) -> ready (model) -> user = model.at('_user') - score = new scoring.Scoring(model) #set cron immediately lastCron = user.get('lastCron') user.set('lastCron', +new Date) if (!lastCron? or lastCron == 'new') - score.cron() + scoring.cron(model) character.app(exports, model) tasks.app(exports, model) @@ -61,6 +61,6 @@ ready (model) -> profile.app(exports, model) require('../server/private').app(exports, model) require('./debug').app(exports, model) if model.get('_view.nodeEnv') != 'production' - browser.app(exports, model) filters.app(exports, model) + browser.app(exports, model, app) diff --git a/src/app/items.coffee b/src/app/items.coffee index 911458e60c..a635efd8b6 100644 --- a/src/app/items.coffee +++ b/src/app/items.coffee @@ -2,40 +2,40 @@ _ = require 'underscore' items = module.exports.items = weapon: [ - {index: 0, text: "Training Sword", classes: "weapon_0", notes:'Training weapon.', modifier: 0.00, value:0} - {index: 1, text: "Sword", classes:'weapon_1', notes:'Increases experience gain by 3%.', modifier: 0.03, value:20} - {index: 2, text: "Axe", classes:'weapon_2', notes:'Increases experience gain by 6%.', modifier: 0.06, value:30} - {index: 3, text: "Morningstar", classes:'weapon_3', notes:'Increases experience gain by 9%.', modifier: 0.09, value:45} - {index: 4, text: "Blue Sword", classes:'weapon_4', notes:'Increases experience gain by 12%.', modifier: 0.12, value:65} - {index: 5, text: "Red Sword", classes:'weapon_5', notes:'Increases experience gain by 15%.', modifier: 0.15, value:90} - {index: 6, text: "Golden Sword", classes:'weapon_6', notes:'Increases experience gain by 18%.', modifier: 0.18, value:120} + {index: 0, text: "Training Sword", classes: "weapon_0", notes:'Training weapon.', strength: 0, value:0} + {index: 1, text: "Sword", classes:'weapon_1', notes:'Increases experience gain by 3%.', strength: 3, value:20} + {index: 2, text: "Axe", classes:'weapon_2', notes:'Increases experience gain by 6%.', strength: 6, value:30} + {index: 3, text: "Morningstar", classes:'weapon_3', notes:'Increases experience gain by 9%.', strength: 9, value:45} + {index: 4, text: "Blue Sword", classes:'weapon_4', notes:'Increases experience gain by 12%.', strength: 12, value:65} + {index: 5, text: "Red Sword", classes:'weapon_5', notes:'Increases experience gain by 15%.', strength: 15, value:90} + {index: 6, text: "Golden Sword", classes:'weapon_6', notes:'Increases experience gain by 18%.', strength: 18, value:120} ] armor: [ - {index: 0, text: "Cloth Armor", classes: 'armor_0', notes:'Training armor.', modifier: 0.00, value:0} - {index: 1, text: "Leather Armor", classes: 'armor_1', notes:'Decreases HP loss by 4%.', modifier: 0.04, value:30} - {index: 2, text: "Chain Mail", classes: 'armor_2', notes:'Decreases HP loss by 6%.', modifier: 0.06, value:45} - {index: 3, text: "Plate Mail", classes: 'armor_3', notes:'Decreases HP loss by 7%.', modifier: 0.07, value:65} - {index: 4, text: "Red Armor", classes: 'armor_4', notes:'Decreases HP loss by 8%.', modifier: 0.08, value:90} - {index: 5, text: "Golden Armor", classes: 'armor_5', notes:'Decreases HP loss by 10%.', modifier: 0.1, value:120} + {index: 0, text: "Cloth Armor", classes: 'armor_0', notes:'Training armor.', defense: 0, value:0} + {index: 1, text: "Leather Armor", classes: 'armor_1', notes:'Decreases HP loss by 4%.', defense: 4, value:30} + {index: 2, text: "Chain Mail", classes: 'armor_2', notes:'Decreases HP loss by 6%.', defense: 6, value:45} + {index: 3, text: "Plate Mail", classes: 'armor_3', notes:'Decreases HP loss by 7%.', defense: 7, value:65} + {index: 4, text: "Red Armor", classes: 'armor_4', notes:'Decreases HP loss by 8%.', defense: 8, value:90} + {index: 5, text: "Golden Armor", classes: 'armor_5', notes:'Decreases HP loss by 10%.', defense: 10, value:120} ] head: [ - {index: 0, text: "No Helm", classes: 'head_0', notes:'Training helm.', modifier: 0.00, value:0} - {index: 1, text: "Leather Helm", classes: 'head_1', notes:'Decreases HP loss by 2%.', modifier: 0.02, value:15} - {index: 2, text: "Chain Coif", classes: 'head_2', notes:'Decreases HP loss by 3%.', modifier: 0.03, value:25} - {index: 3, text: "Plate Helm", classes: 'head_3', notes:'Decreases HP loss by 4%.', modifier: 0.04, value:45} - {index: 4, text: "Red Helm", classes: 'head_4', notes:'Decreases HP loss by 5%.', modifier: 0.05, value:60} - {index: 5, text: "Golden Helm", classes: 'head_5', notes:'Decreases HP loss by 6%.', modifier: 0.06, value:80} + {index: 0, text: "No Helm", classes: 'head_0', notes:'Training helm.', defense: 0, value:0} + {index: 1, text: "Leather Helm", classes: 'head_1', notes:'Decreases HP loss by 2%.', defense: 2, value:15} + {index: 2, text: "Chain Coif", classes: 'head_2', notes:'Decreases HP loss by 3%.', defense: 3, value:25} + {index: 3, text: "Plate Helm", classes: 'head_3', notes:'Decreases HP loss by 4%.', defense: 4, value:45} + {index: 4, text: "Red Helm", classes: 'head_4', notes:'Decreases HP loss by 5%.', defense: 5, value:60} + {index: 5, text: "Golden Helm", classes: 'head_5', notes:'Decreases HP loss by 6%.', defense: 6, value:80} ] shield: [ - {index: 0, text: "No Shield", classes: 'shield_0', notes:'No Shield.', modifier: 0.00, value:0} - {index: 1, text: "Wooden Shield", classes: 'shield_1', notes:'Decreases HP loss by 3%', modifier: 0.03, value:20} - {index: 2, text: "Buckler", classes: 'shield_2', notes:'Decreases HP loss by 4%.', modifier: 0.04, value:35} - {index: 3, text: "Enforced Shield", classes: 'shield_3', notes:'Decreases HP loss by 5%.', modifier: 0.05, value:55} - {index: 4, text: "Red Shield", classes: 'shield_4', notes:'Decreases HP loss by 7%.', modifier: 0.07, value:70} - {index: 5, text: "Golden Shield", classes: 'shield_5', notes:'Decreases HP loss by 8%.', modifier: 0.08, value:90} + {index: 0, text: "No Shield", classes: 'shield_0', notes:'No Shield.', defense: 0, value:0} + {index: 1, text: "Wooden Shield", classes: 'shield_1', notes:'Decreases HP loss by 3%', defense: 3, value:20} + {index: 2, text: "Buckler", classes: 'shield_2', notes:'Decreases HP loss by 4%.', defense: 4, value:35} + {index: 3, text: "Enforced Shield", classes: 'shield_3', notes:'Decreases HP loss by 5%.', defense: 5, value:55} + {index: 4, text: "Red Shield", classes: 'shield_4', notes:'Decreases HP loss by 7%.', defense: 7, value:70} + {index: 5, text: "Golden Shield", classes: 'shield_5', notes:'Decreases HP loss by 8%.', defense: 8, value:90} ] potion: {type: 'potion', text: "Potion", notes: "Recover 15 HP", value: 25, classes: 'potion'} - reroll: {type: 'reroll', text: "Re-Roll", classes: 'reroll', notes: "Resets your tasks. When you're struggling and everything's red, use for a clean slate.", value:0 } + reroll: {type: 'reroll', text: "Re-Roll", classes: 'reroll', notes: "Resets your task values back to 0 (yellow). Useful when everything's red and it's hard to stay alive.", value:0 } pets: [ {index: 0, text: 'Bear Cub', name: 'bearcub', icon: 'Pet-BearCub-Base.png', value: 3} @@ -165,22 +165,22 @@ module.exports.app = (appExports, model) -> model.set '_view.activeTabPets', true model.set '_view.activeTabRewards', false - user.on 'set', 'flags.itemsEnabled', (captures, args) -> - return unless captures == true + model.on 'set', '_user.flags.itemsEnabled', (captures, args) -> + return unless captures is true html = """

- + Congratulations, you have unlocked the Item Store! You can now buy weapons, armor, potions, etc. Read each item's comment for more information. - [Close] + [Close]
""" - $('ul.items').popover + $('div.rewards').popover({ title: "Item Store Unlocked" placement: 'left' trigger: 'manual' html: true content: html - $('ul.items').popover 'show' + }).popover 'show' user.on 'set', 'flags.petsEnabled', (captures, args) -> return unless captures == true diff --git a/src/app/party.coffee b/src/app/party.coffee index fe5f795277..30592b5557 100644 --- a/src/app/party.coffee +++ b/src/app/party.coffee @@ -71,8 +71,7 @@ module.exports.partySubscribe = partySubscribe = (page, model, params, next, cb) module.exports.app = (appExports, model) -> user = model.at('_user') - user.on 'set', 'flags.partyEnabled', (captures, args) -> - return unless captures == true + unlockPartiesNotification = -> $('.main-avatar').popover('destroy') #remove previous popovers html = """
@@ -89,6 +88,14 @@ module.exports.app = (appExports, model) -> content: html $('.main-avatar').popover 'show' + appExports.manuallyUnlockParties = -> + $("#settings-modal").modal("hide") + user.set('flags.partyEnabled', true) + unlockPartiesNotification() + + user.on 'set', 'flags.partyEnabled', (captures, args) -> + unlockPartiesNotification() if captures == true + #TODO implement this when we have unsubscribe working properly model.on 'set', '_user.party.invitation', (after, before) -> if !before? and after? # they just got invited diff --git a/src/app/profile.coffee b/src/app/profile.coffee index d2b78d0aea..1a84de042e 100644 --- a/src/app/profile.coffee +++ b/src/app/profile.coffee @@ -20,6 +20,6 @@ module.exports.app = (appExports, model) -> appExports.profileChangeActive = (e, el) -> uid = $(el).attr('data-uid') model.ref '_profileActive', model.at("users.#{uid}") - model.set '_profileActiveMain', model.get('_user.id') == uid + model.set '_profileActiveMain', user.get('id') is uid model.set '_profileActiveUsername', character.username model.get('_profileActive.auth') diff --git a/src/app/scoring.coffee b/src/app/scoring.coffee index 4f1b78db70..4a4882c02d 100644 --- a/src/app/scoring.coffee +++ b/src/app/scoring.coffee @@ -5,269 +5,261 @@ helpers = require './helpers' browser = require './browser' character = require './character' items = require './items' +algos = require './algos' -module.exports.Scoring = (model) -> +MODIFIER = algos.MODIFIER # each new level, armor, weapon add 2% modifier (this mechanism will change) - MODIFIER = .02 # each new level, armor, weapon add 2% modifier (this mechanism will change) +# {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 = (model, taskId, direction, times, batch, cron) -> user = model.at '_user' - ### - 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 + commit = false + unless batch? + commit = true + batch = new character.BatchUpdate(model) + batch.startTransaction() + 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 + {gp, hp, exp, lvl} = obj.stats - ### - 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 + taskPath = "tasks.#{taskId}" + taskObj = obj.tasks[taskId] + {type, value} = taskObj + priority = taskObj.priority or '!' - ### - 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() + # 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 - # if user is dead, dont do anything - return if obj.stats.lvl == 0 + 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 = algos.taskDeltaFormula(value, direction) + value += nextDelta if adjustvalue + delta += nextDelta - if newStats.hp? - # Game Over - if newStats.hp <= 0 - obj.stats.lvl = 0 # signifies dead - obj.stats.hp = 0 - return + addPoints = -> + level = user.get('stats.lvl') + weaponStrength = items.items.weapon[user.get('items.weapon')].strength + exp += algos.expModifier(delta,weaponStrength,level, priority) / 2 # / 2 hack for now bcause people leveling too fast + gp += algos.gpModifier(delta, 1, priority) + + subtractPoints = -> + level = user.get('stats.lvl') + armorDefense = items.items.armor[user.get('items.armor')].defense + helmDefense = items.items.head[user.get('items.head')].defense + shieldDefense = items.items.shield[user.get('items.shield')].defense + hp += algos.hpModifier(delta,armorDefense,helmDefense,shieldDefense,level, priority) + + switch type + when 'habit' + calculateDelta() + # 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' + if cron? # cron + calculateDelta() + subtractPoints() else - obj.stats.hp = newStats.hp + calculateDelta(false) + if delta != 0 + addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes - if newStats.exp? + when 'todo' + if cron? #cron + calculateDelta() + #don't touch stats on cron + else + calculateDelta() + 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 model, {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 + +### + 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 = (model, newStats, batch) -> + user = model.at '_user' + obj = batch.obj() + + # 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? + tnl = algos.tnl(obj.stats.lvl) + #silent = false + # if we're at level 100, turn xp to gold + if obj.stats.lvl >= 100 + newStats.gp += newStats.exp / 15 + newStats.exp = 0 + obj.stats.lvl = 100 + else # level up & carry-over exp - tnl = model.get '_tnl' if newStats.exp >= tnl - newStats.exp -= tnl - obj.stats.lvl++ + #silent = true # push through the negative xp silently + user.set('stats.exp', newStats.exp) # push normal + notification + while newStats.exp >= tnl and obj.stats.lvl < 100 # keep levelling up + newStats.exp -= tnl + obj.stats.lvl++ + tnl = algos.tnl(obj.stats.lvl) + if obj.stats.lvl== 100 + newStats.exp = 0 obj.stats.hp = 50 - obj.stats.exp = newStats.exp + obj.stats.exp = newStats.exp + #if silent + #console.log("pushing silent :" + obj.stats.exp) + #user.pass(true).set('stats.exp', obj.stats.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 + # 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 + 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() +### + At end of day, add value to all incomplete Daily & Todo tasks (further incentive) + For incomplete Dailys, deduct experience +### +cron = (model) -> + user = model.at '_user' + today = +new Date + daysPassed = helpers.daysBetween(user.get('lastCron'), today, user.get('preferences.dayStart')) + 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 model, id, 'down', daysFailed, batch, true + if type == 'daily' + if completed #set OHV for completed dailies + newValue = taskObj.value + algos.taskDeltaFormula(taskObj.value,'up') + batch.set "tasks.#{taskObj.id}.value", newValue - {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() + taskObj.history ?= [] + taskObj.history.push { date: +new Date, value: taskObj.value } + batch.set "tasks.#{taskObj.id}.history", taskObj.history + batch.set "tasks.#{taskObj.id}.completed", false 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 - 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 + value = obj.tasks[taskObj.id].value #get updated value + absVal = if (completed) then Math.abs(value) else value + todoTally += absVal + else if type is 'habit' # slowly reset 'onlies' value to 0 + if taskObj.up==false or taskObj.down==false + if Math.abs(taskObj.value) < 0.1 + batch.set "tasks.#{taskObj.id}.value", 0 else - value = obj.tasks[taskObj.id].value #get updated value - absVal = if (completed) then Math.abs(value) else value - todoTally += absVal + batch.set "tasks.#{taskObj.id}.value", taskObj.value / 2 - # 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 } + # 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 += algos.tnl(lvl) + 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 + # 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 - return { - MODIFIER: MODIFIER - score: score - cron: cron +module.exports = { + score: score + cron: cron - # testing stuff - expModifier: expModifier - hpModifier: hpModifier - taskDeltaFormula: taskDeltaFormula - } + # testing stuff + expModifier: algos.expModifier + hpModifier: algos.hpModifier + taskDeltaFormula: algos.taskDeltaFormula +} diff --git a/src/app/tasks.coffee b/src/app/tasks.coffee index c6a989a9d0..54f8488b9d 100644 --- a/src/app/tasks.coffee +++ b/src/app/tasks.coffee @@ -2,48 +2,45 @@ scoring = require './scoring' helpers = require './helpers' _ = require 'underscore' moment = require 'moment' +character = require './character' module.exports.view = (view) -> - view.fn 'taskClasses', (type, completed, value, repeat, tags = {}, filters = {}) -> - #TODO figure out how to just pass in the task model, so i can access all these properties from one object - if type != 'reward' - for filter, enabled of filters - if enabled and not tags[filter] - # All the other classes don't matter - return 'filtered-out' + view.fn 'taskClasses', (task, filters) -> + return unless task + {type, completed, value, repeat} = task + + for filter, enabled of filters + if enabled and not task.tags?[filter] + # All the other classes don't matter + return 'hide' classes = type # show as completed if completed (naturally) or not required for today - if completed or (repeat and repeat[helpers.dayMapping[moment().day()]]==false) - classes += " completed" - else - classes += " uncompleted" + if type in ['todo', 'daily'] + if completed or (repeat and repeat[helpers.dayMapping[moment().day()]]==false) + classes += " completed" + else + classes += " uncompleted" - switch - when value<-8 then classes += ' color-worst' - when value>=-8 and value<-5 then classes += ' color-worse' - when value>=-5 and value<-1 then classes += ' color-bad' - when value>=-1 and value<1 then classes += ' color-neutral' - when value>=1 and value<5 then classes += ' color-good' - when value>=5 and value<10 then classes += ' color-better' - when value>=10 then classes += ' color-best' + if value < -20 + classes += ' color-worst' + else if value < -10 + classes += ' color-worse' + else if value < -1 + classes += ' color-bad' + else if value < 1 + classes += ' color-neutral' + else if value < 5 + classes += ' color-good' + else if value < 10 + classes += ' color-better' + else + classes += ' color-best' return classes 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 - direction = () -> - return 'up' if completed==true and previous == false - return 'down' if completed==false and previous == true - throw new Error("Direction neither 'up' nor 'down' on checkbox set.") - - # Score the user based on todo task - task = user.at("tasks.#{i}") - score.score(i, direction()) appExports.addTask = (e, el, next) -> type = $(el).attr('data-task-type') @@ -76,7 +73,7 @@ module.exports.app = (appExports, model) -> appExports.del = (e, el) -> # Derby extends model.at to support creation from DOM nodes - task = model.at(e.target) + task = e.at() id = task.get('id') history = task.get('history') @@ -88,7 +85,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" - score.score(id, direction:'down') + scoring.score(model, id, direction:'down') # prevent accidently deleting long-standing tasks else @@ -124,8 +121,8 @@ module.exports.app = (appExports, model) -> appExports.toggleTaskEdit = (e, el) -> hideId = $(el).attr('data-hide-id') toggleId = $(el).attr('data-toggle-id') - $(document.getElementById(hideId)).hide() - $(document.getElementById(toggleId)).toggle() + $(document.getElementById(hideId)).addClass('visuallyhidden') + $(document.getElementById(toggleId)).toggleClass('visuallyhidden') appExports.toggleChart = (e, el) -> hideSelector = $(el).attr('data-hide-id') @@ -142,9 +139,9 @@ module.exports.app = (appExports, model) -> data = google.visualization.arrayToDataTable matrix options = { - title: 'History' - #TODO use current background color: $(el).css('background-color), but convert to hex (see http://goo.gl/ql5pR) - backgroundColor: 'whiteSmoke' + title: 'History' + #TODO use current background color: $(el).css('background-color), but convert to hex (see http://goo.gl/ql5pR) + backgroundColor: 'whiteSmoke' } chart = new google.visualization.LineChart(document.getElementById( chartSelector )) @@ -180,9 +177,70 @@ module.exports.app = (appExports, model) -> path = "_user.tasks.#{taskId}.tags.#{tagId}" model.set path, !(model.get path) - appExports.score = (e, el, next) -> + + setUndo = (stats, task) -> + previousUndo = model.get('_undo') + clearTimeout(previousUndo.timeoutId) if previousUndo?.timeoutId + timeoutId = setTimeout (-> model.del('_undo')), 10000 + model.set '_undo', {stats:stats, task:task, timeoutId: timeoutId} + + + ### + Call scoring functions for habits & rewards (todos & dailies handled below) + ### + appExports.score = (e, el) -> + task= model.at $(el).parents('li')[0] + taskObj = task.get() direction = $(el).attr('data-direction') - direction = 'up' if direction == 'true/' - direction = 'down' if direction == 'false/' - task = model.at $(el).parents('li')[0] - score.score(task.get('id'), direction) + + # set previous state for undo + setUndo _.clone(user.get('stats')), _.clone(taskObj) + + scoring.score(model, taskObj.id, direction) + + ### + This is how we handle appExports.score for todos & dailies. Due to Derby's special handling of `checked={:task.completd}`, + the above function doesn't work so we need a listener here + ### + user.on 'set', 'tasks.*.completed', (i, completed, previous, isLocal, passed) -> + return if passed? && passed.cron # Don't do this stuff on cron + direction = if completed then 'up' else 'down' + + # set previous state for undo + taskObj = _.clone user.get("tasks.#{i}") + taskObj.completed = previous + setUndo _.clone(user.get('stats')), taskObj + + scoring.score(model, i, direction) + + ### + Undo + ### + appExports.undo = () -> + undo = model.get '_undo' + clearTimeout(undo.timeoutId) if undo?.timeoutId + batch = character.BatchUpdate(model) + batch.startTransaction() + model.del '_undo' + _.each undo.stats, (val, key) -> batch.set "stats.#{key}", val + taskPath = "tasks.#{undo.task.id}" + _.each undo.task, (val, key) -> + return if key in ['id', 'type'] # strange bugs in this world: https://workflowy.com/shared/a53582ea-43d6-bcce-c719-e134f9bf71fd/ + if key is 'completed' + user.pass({cron:true}).set("#{taskPath}.completed",val) + else + batch.set "#{taskPath}.#{key}", val + batch.commit() + + appExports.tasksToggleAdvanced = (e, el) -> + $(el).next('.advanced-option').toggleClass('visuallyhidden') + + appExports.tasksSaveAndClose = -> + # When they update their notes, re-establish tooltip & popover + $('[rel=tooltip]').tooltip() + $('[rel=popover]').popover() + + appExports.tasksSetPriority = (e, el) -> + dataId = $(el).parent('[data-id]').attr('data-id') + #"_user.tasks.#{dataId}" + model.at(e.target).set 'priority', $(el).attr('data-priority') diff --git a/src/server/api.coffee b/src/server/api.coffee index ef18e8d233..d5ea019f8e 100644 --- a/src/server/api.coffee +++ b/src/server/api.coffee @@ -3,82 +3,191 @@ router = new express.Router() scoring = require '../app/scoring' _ = require 'underscore' -icalendar = require('icalendar') +{ tnl } = require '../app/algos' +validator = require 'derby-auth/node_modules/validator' +check = validator.check +sanitize = validator.sanitize -# ---------- /v1 API ------------ -# Every url added beneath router is prefaced by /v1 +NO_TOKEN_OR_UID = err: "You must include a token and uid (user id) in your request" +NO_USER_FOUND = err: "No user found." + +# ---------- /api/v1 API ------------ +# Every url added beneath router is prefaced by /api/v1 ### - v1 API. Requires user-id and apiToken, task-id, direction. Test with: - curl -X POST -H "Content-Type:application/json" -d '{"apiToken":"{TOKEN}"}' localhost:3000/v1/users/{UID}/tasks/productivity/up + v1 API. Requires api-v1-user (user id) and api-v1-key (api key) headers, Test with: + $ cd node_modules/racer && npm install && cd ../.. + $ mocha test/api.mocha.coffee ### -router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> - {uid, taskId, direction} = req.params - {apiToken, title, service, icon} = req.body - console.log {params:req.params, body:req.body} if process.env.NODE_ENV == 'development' +### + API Status +### +router.get '/status', (req, res) -> + res.json status: 'up' + +### + beforeEach auth interceptor +### +auth = (req, res, next) -> + uid = req.headers['x-api-user'] + token = req.headers['x-api-key'] + return res.json 401, NO_TOKEN_OR_UID unless uid || token + + model = req.getModel() + query = model.query('users').withIdAndToken(uid, token) + + query.fetch (err, user) -> + return res.json err: err if err + req.user = user + req.userObj = user.get() + return res.json 401, NO_USER_FOUND if !req.userObj || _.isEmpty(req.userObj) + req._isServer = true + next() +### + GET /user +### +router.get '/user', auth, (req, res) -> + user = req.userObj + + user.stats.toNextLevel = tnl user.stats.lvl + user.stats.maxHealth = 50 + + delete user.apiToken + + res.json user + +### + GET /user/task/:id +### +router.get '/user/task/:id', auth, (req, res) -> + task = req.userObj.tasks[req.params.id] + return res.json 400, err: "No task found." if !task || _.isEmpty(task) + + res.json 200, task + +### + validate task +### +validateTask = (req, res, next) -> + task = {} + newTask = { type, text, notes, value, up, down, completed } = req.body + + # If we're updating, get the task from the user + if req.method is 'PUT' or req.method is 'DELETE' + task = req.userObj?.tasks[req.params.id] + return res.json 400, err: "No task found." if !task || _.isEmpty(task) + # Strip for now + type = undefined + delete newTask.type + else if req.method is 'POST' + unless /^(habit|todo|daily|reward)$/.test type + return res.json 400, err: 'type must be habit, todo, daily, or reward' + + text = sanitize(text).xss() + notes = sanitize(notes).xss() + value = sanitize(value).toInt() + + switch type + when 'habit' + newTask.up = true unless typeof up is 'boolean' + newTask.down = true unless typeof down is 'boolean' + when 'daily', 'todo' + newTask.completed = false unless typeof completed is 'boolean' + + _.extend task, newTask + req.task = task + next() + +### + PUT /user/task/:id +### +router.put '/user/task/:id', auth, validateTask, (req, res) -> + req.user.set "tasks.#{req.task.id}", req.task + + res.json 200, req.task + +### + DELETE /user/task/:id +### +router.delete '/user/task/:id', auth, validateTask, (req, res) -> + taskIds = req.user.get "#{req.task.type}Ids" + + req.user.del "tasks.#{req.task.id}" + # Remove one id from array of typeIds + req.user.remove "#{req.task.type}Ids", taskIds.indexOf(req.task.id), 1 + + res.send 204 + +### + POST /user/task/ +### +router.post '/user/task', auth, validateTask, (req, res) -> + task = req.task + type = task.type + + model = req.getModel() + model.ref '_user', req.user + model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" + model.at("_#{type}List").push task + + res.json 201, task + +### + GET /user/tasks +### +router.get '/user/tasks', auth, (req, res) -> + user = req.userObj + return res.json 400, NO_USER_FOUND if !user || _.isEmpty(user) + + model = req.getModel() + model.ref '_user', req.user + tasks = [] + types = ['habit','todo','daily','reward'] + if /^(habit|todo|daily|reward)$/.test req.query.type + types = [req.query.type] + for type in types + model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" + tasks = tasks.concat model.get("_#{type}List") + + res.json 200, tasks + +### + This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login +### +scoreTask = (req, res, next) -> + {taskId, direction} = req.params + {title, service, icon} = req.body # Send error responses for improper API call - return res.send(500, 'request body "apiToken" required') unless apiToken - return res.send(500, ':uid required') unless uid return res.send(500, ':taskId required') unless taskId return res.send(500, ":direction must be 'up' or 'down'") unless direction in ['up','down'] - req._isServer = true model = req.getModel() - model.fetch model.query('users').withIdAndToken(uid, apiToken), (err, result) -> - return res.send(500, err) if err - user = result - userObj = user.get() - if _.isEmpty(userObj) - return res.send(500, "User with uid=#{uid}, token=#{apiToken} not found. Make sure you're not using your username, but your User Id") + {user, userObj} = req - model.ref('_user', user) + model.ref('_user', user) - # Create task if doesn't exist - # TODO add service & icon to task - unless model.get("_user.tasks.#{taskId}") - model.refList "_habitList", "_user.tasks", "_user.habitIds" - model.at('_habitList').push - id: taskId - type: 'habit' - text: (title || taskId) - value: 0 - up: true - 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." + # Create task if doesn't exist + # TODO add service & icon to task + unless model.get("_user.tasks.#{taskId}") + model.refList "_habitList", "_user.tasks", "_user.habitIds" + model.at('_habitList').push + id: taskId + type: 'habit' + text: (title || taskId) + value: 0 + up: true + 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." - score = scoring.Scoring(model) - delta = score.score(taskId, direction) - result = model.get ('_user.stats') - result.delta = delta - res.send(result) + delta = scoring.score(model, taskId, direction) + result = model.get ('_user.stats') + result.delta = delta + res.send(result) -router.get '/users/:uid/calendar.ics', (req, res) -> - #return next() #disable for now - {uid} = req.params - {apiToken} = req.query - - model = req.getModel() - query = model.query('users').withIdAndToken(uid, apiToken) - query.fetch (err, result) -> - return res.send(500, err) if err - tasks = result.get('tasks') - # tasks = result[0].tasks - tasksWithDates = _.filter tasks, (task) -> !!task.date - return res.send(500, "No events found") if _.isEmpty(tasksWithDates) - - ical = new icalendar.iCalendar() - ical.addProperty('NAME', 'HabitRPG') - _.each tasksWithDates, (task) -> - event = new icalendar.VEvent(task.id); - event.setSummary(task.text); - d = new Date(task.date) - d.date_only = true - event.setDate d - ical.addComponent event - res.type('text/calendar') - formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:') - res.send(200, formattedIcal) +router.post '/user/tasks/:taskId/:direction', auth, scoreTask module.exports = router +module.exports.auth = auth +module.exports.scoreTask = scoreTask # export so deprecated can call it diff --git a/src/server/deprecated.coffee b/src/server/deprecated.coffee index e8a5fb7340..3fcef2c8a3 100644 --- a/src/server/deprecated.coffee +++ b/src/server/deprecated.coffee @@ -1,6 +1,11 @@ express = require 'express' router = new express.Router() +scoring = require '../app/scoring' +_ = require 'underscore' +icalendar = require('icalendar') +api = require './api' + # ---------- Deprecated Paths ------------ deprecatedMessage = 'This API is no longer supported, see https://github.com/lefnire/habitrpg/wiki/API for new protocol' @@ -9,4 +14,39 @@ router.get '/:uid/up/:score?', (req, res) -> res.send(500, deprecatedMessage) router.get '/:uid/down/:score?', (req, res) -> res.send(500, deprecatedMessage) router.post '/users/:uid/tasks/:taskId/:direction', (req, res) -> res.send(500, deprecatedMessage) +# Redirect to new API +initDeprecated = (req, res, next) -> + req.headers['x-api-user'] = req.params.uid + req.headers['x-api-key'] = req.body.apiToken + next() + +router.post '/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, api.auth, api.scoreTask + +router.get '/v1/users/:uid/calendar.ics', (req, res) -> + #return next() #disable for now + {uid} = req.params + {apiToken} = req.query + + model = req.getModel() + query = model.query('users').withIdAndToken(uid, apiToken) + query.fetch (err, result) -> + return res.send(500, err) if err + tasks = result.get('tasks') + # tasks = result[0].tasks + tasksWithDates = _.filter tasks, (task) -> !!task.date + return res.send(500, "No events found") if _.isEmpty(tasksWithDates) + + ical = new icalendar.iCalendar() + ical.addProperty('NAME', 'HabitRPG') + _.each tasksWithDates, (task) -> + event = new icalendar.VEvent(task.id); + event.setSummary(task.text); + d = new Date(task.date) + d.date_only = true + event.setDate d + ical.addComponent event + res.type('text/calendar') + formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:') + res.send(200, formattedIcal) + module.exports = router diff --git a/src/server/index.coffee b/src/server/index.coffee index 05beb1f7ec..f09668dee0 100644 --- a/src/server/index.coffee +++ b/src/server/index.coffee @@ -31,7 +31,7 @@ server = http.createServer expressApp module.exports = server derby.use require('racer-db-mongo') -store = derby.createStore +module.exports.habitStore = store = derby.createStore db: {type: 'Mongo', uri: process.env.NODE_DB_URI, safe:true} listen: server @@ -57,6 +57,7 @@ auth.store(store, habitrpgStore.customAccessControl) mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, -> expressApp + .use(middleware.allowCrossDomain) .use(express.favicon("#{publicPath}/favicon.ico")) # Gzip static files and serve from memory .use(gzippo.staticGzip(publicPath, maxAge: ONE_YEAR)) @@ -74,6 +75,9 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, -> ) # Adds req.getModel method .use(store.modelMiddleware()) + # API should be hit before all other routes + .use('/api/v1', require('./api').middleware) + .use(require('./deprecated').middleware) # Show splash page for newcomers .use(middleware.splash) .use(priv.middleware) @@ -81,9 +85,7 @@ mongo_store = new MongoStore {url: process.env.NODE_DB_URI}, -> .use(auth.middleware(strategies, options)) # Creates an express middleware from the app's routes .use(app.router()) - .use('/v1', require('./api').middleware) .use(require('./static').middleware) - .use(require('./deprecated').middleware) .use(expressApp.router) .use(serverError(root)) diff --git a/src/server/middleware.coffee b/src/server/middleware.coffee index 03e388ffee..60a392faee 100644 --- a/src/server/middleware.coffee +++ b/src/server/middleware.coffee @@ -1,17 +1,27 @@ module.exports.splash = (req, res, next) -> - # This was an API call, not a page load - return next() if req.is('json') - - unless req.query?.play? or req.getModel().get('_userId') - res.redirect('/splash.html') + isStatic = req.url.split('/')[1] is 'static' + unless req.query?.play? or req.getModel().get('_userId') or isStatic + res.redirect('/static/front') else next() module.exports.view = (req, res, next) -> - model = req.getModel() - _view = model.get('_view') || {} - ## Set _mobileDevice to true or false so view can exclude portions from mobile device - _view.mobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header 'User-Agent') - _view.nodeEnv = process.env.NODE_ENV - model.set '_view', _view - next() + model = req.getModel() + _view = model.get('_view') || {} + ## Set _mobileDevice to true or false so view can exclude portions from mobile device + _view.mobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header 'User-Agent') + _view.nodeEnv = process.env.NODE_ENV + model.set '_view', _view + next() + +#CORS middleware +module.exports.allowCrossDomain = (req, res, next) -> + res.header "Access-Control-Allow-Origin", (req.headers.origin || "*") + res.header "Access-Control-Allow-Methods", "OPTIONS,GET,POST,PUT,HEAD,DELETE" + res.header "Access-Control-Allow-Headers", "Content-Type,X-Requested-With,x-api-user,x-api-key" + + # wtf is this for? + if req.method is 'OPTIONS' + res.send(200); + else + next() \ No newline at end of file diff --git a/src/server/static.coffee b/src/server/static.coffee index 1d5124f35e..6ed41a41c4 100644 --- a/src/server/static.coffee +++ b/src/server/static.coffee @@ -7,10 +7,18 @@ derby = require 'derby' # ---------- Static Pages ------------ staticPages = derby.createStatic path.dirname(path.dirname(__dirname)) -router.get '/privacy', (req, res) -> - staticPages.render 'privacy', res +beforeEach = (req, res, next) -> + req.getModel().set '_view', {nodeEnv: 'production'} # we don't want cheat buttons on static pages + next() -router.get '/terms', (req, res) -> - staticPages.render 'terms', res +router.get '/splash.html', (req, res) -> return res.redirect('/static/front') +router.get '/static/front', beforeEach, (req, res) -> staticPages.render 'static/front', res +router.get '/static/about', beforeEach, (req, res) -> staticPages.render 'static/about', res +router.get '/static/team', beforeEach, (req, res) -> staticPages.render 'static/team', res +router.get '/static/extensions', beforeEach, (req, res) -> staticPages.render 'static/extensions', res +router.get '/static/faq', beforeEach, (req, res) -> staticPages.render 'static/faq', res + +router.get '/static/privacy', beforeEach, (req, res) -> staticPages.render 'static/privacy', res +router.get '/static/terms', beforeEach, (req, res) -> staticPages.render 'static/terms', res module.exports = router diff --git a/src/server/store.coffee b/src/server/store.coffee index 3ee0f45b24..b83ef268a4 100644 --- a/src/server/store.coffee +++ b/src/server/store.coffee @@ -28,7 +28,10 @@ userAccess = (store) -> store.writeAccess "*", "users.*", -> # captures, value, accept, err -> accept = arguments[arguments.length-2] err = arguments[arguments.length - 1] -# return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@) + # return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@) + + return accept(true) if derbyAuth.isServer(@) + return accept(false) if derbyAuth.bustedSession(@) captures = arguments[0].split('.') @@ -40,8 +43,7 @@ userAccess = (store) -> return accept(true) # Same session (user.id = this.session.userId) - if (uid is @session.userId) or derbyAuth.isServer(@) - return accept(true) + return accept(true) if uid is @session.userId accept(false) @@ -51,9 +53,10 @@ userAccess = (store) -> oldBalance = @session.req?._racerModel?.get("users.#{id}.balance") || 0 purchasingSomethingOnClient = newBalance < oldBalance - accept(purchasingSomethingOnClient or @session.req?._isServer) + accept(purchasingSomethingOnClient or derbyAuth.isServer(@)) store.writeAccess "*", "users.*.flags.ads", -> # captures, value, accept, err -> + accept = arguments[arguments.length - 2] err = arguments[arguments.length - 1] # return err(derbyAuth.SESSION_INVALIDATED_ERROR) if derbyAuth.bustedSession(@) return accept(false) if derbyAuth.bustedSession(@) @@ -67,7 +70,7 @@ userAccess = (store) -> ### REST = (store) -> store.query.expose "users", "withIdAndToken", (uid, token) -> - @byId(uid) + @where("id").equals(uid) .where('apiToken').equals(token) .findOne() diff --git a/styles/app/alerts.styl b/styles/app/alerts.styl index 8ecbd0e45e..4cf63147c9 100644 --- a/styles/app/alerts.styl +++ b/styles/app/alerts.styl @@ -8,6 +8,8 @@ lvlColor = #d9edf7 lvlText = #3a87ad xpColor = #DCC0FB xpText = #635673 +deathColor = #4E4E4E +deathText = #ABABAB borderDarken = 20% // alert styles @@ -32,11 +34,36 @@ borderDarken = 20% border-color: darken(xpColor,borderDarken) color: xpText +.alert-death + background-color: deathColor + border-color: deathColor + color: deathText + // alert icons -.icon-gp - background: url("img/sprites/shop_sprites.png") no-repeat - background-size: 400px - background-position: -386px 0 +.icon-gold + background: url("img/coin_single_gold.png") no-repeat + background-position: center center + background-size: 18px width: 14px - height: 14px \ No newline at end of file + height: 14px + +.icon-silver + background: url("img/coin_single_silver.png") no-repeat + background-position: center center + background-size: 18px + width: 14px + height: 14px + +.icon-death + background: url("img/sprites/dead.png") no-repeat + background-position: center center + background-size: 14px + width: 14px + height: 14px + + +.undo-button + position:absolute + left:5px + top:5px \ No newline at end of file diff --git a/styles/app/helpers.styl b/styles/app/helpers.styl new file mode 100644 index 0000000000..e827803593 --- /dev/null +++ b/styles/app/helpers.styl @@ -0,0 +1,32 @@ +// Hide from both screenreaders and browsers: h5bp.com/u +.hidden + display: none !important + visibility: hidden + + +// Hide only visually, but have it available for screenreaders: h5bp.com/v +.visuallyhidden + border: 0 + clip: rect(0 0 0 0) + height: 1px + margin: -1px + overflow: hidden + padding: 0 + position: absolute + width: 1px + + +// Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p +.visuallyhidden.focusable:active, +.visuallyhidden.focusable:focus + clip: auto + height: auto + margin: 0 + overflow: visible + position: static + width: auto + + +// Hide visually and from screenreaders, but maintain layout +.invisible + visibility: hidden \ No newline at end of file diff --git a/styles/app/index.styl b/styles/app/index.styl index 534dc6283e..951745a510 100644 --- a/styles/app/index.styl +++ b/styles/app/index.styl @@ -8,13 +8,31 @@ @import "./items.styl"; @import "./alerts.styl"; @import "./filters.styl"; +@import "./helpers.styl"; +@import "./responsive.styl"; +@import "./scrollbars.styl"; @import "../../public/vendor/bootstrap-datepicker/css/datepicker.css"; +// fix exploding to very wide for some reason +.datepicker + width: 210px + html,body,p,h1,ul,li,table,tr,th,td margin: 0 padding: 0 +* + -webkit-box-sizing: border-box + -moz-box-sizing: border-box + box-sizing: border-box + +hr + border-top: 0 + border-bottom: 1px solid #ddd + border-color: rgba(0,0,0,0.1) + + /* Header -------------------------------------------------- */ @@ -35,7 +53,7 @@ html,body,p,h1,ul,li,table,tr,th,td box-sizing: border-box z-index: 1000 -@media (max-width: 480px) { +@media (max-width: 600px) { #head { position: static; } @@ -97,16 +115,6 @@ html,body,p,h1,ul,li,table,tr,th,td /* Footer -------------------------------------------------- */ -.footer ul li - list-style:none - float:left -.footer ul li:after - content: "\00A0\00A0|\00A0\00A0" -.footer ul li:last-child:after - content: "" -.footer table td - vertical-align: top - /* Bootstrap website boilerplate */ .footer { padding: 70px 0; @@ -132,7 +140,7 @@ html,body,p,h1,ul,li,table,tr,th,td /*overflow:auto;*/ padding-bottom: 250px; /* don't know why this works, sticky footers are weird */ -@media (max-width: 480px) { +@media (max-width: 600px) { #wrap { margin-top: 0 } diff --git a/styles/app/items.styl b/styles/app/items.styl index eaf078a71b..b90d2ea53a 100644 --- a/styles/app/items.styl +++ b/styles/app/items.styl @@ -1,42 +1,83 @@ -/* ----- Items, Weapons, Armor -----*/ -.item-store-popover img - float:left - padding-right:7px; +// money styles +.money + display: inline-block + line-height: 1.5em + padding-left: 0.75em -.item .task-text img - max-height: 16px + [class^="shop_"] + vertical-align: top + display: inline-block -.buy-link, .item-buy-link - background-color: #DEE5F2 - padding: 2px - margin-right: 10px - border-radius: 5px +.btn-buy + width: 4.5em + height: 3em + padding: 0.75em 0 0 + text-align: center + vertical-align: top + background-color: $better - img - height:20px + color: #555 + &:hover, &:focus + background-color: $bad + text-decoration: none - .item-buy-link - background-color: #ccffcc +.rewards + margin-bottom: 1.5em + padding-bottom: 1.5em + border-bottom: 1px solid rgba(0,0,0,0.1) -.item - position:relative +.reward-item + background: white -.shop-table - position:absolute - top: 0 - left: 50px +.reward-cost + display: inline -#money - position:relative -.shop_gold, .shop_silver, .shop_copper - float:right -.money-table - position:absolute - top: 0 -.shop-pet - mouse: pointer -.shop-pet-owned-check - position:absolute - right:0px - bottom:0px +// store items +.btn-reroll + background-color: $better + cursor: pointer + box-shadow: inset -1px -1px 0 rgba(0,0,0,0.1) + &:hover, &:focus + background-color: $bad + +.item-img + display: inline-block + vertical-align: top + // background-color: $bad + height: 3em + margin-left: -0.5em + margin-right: 0.5em + // border: 1px solid rgba(0,0,0,0.1) + border-top: 0 + border-bottom: 0 + +.token-wallet + cursor: pointer + .tile + background-color: darken($neutral, 10%) + .add-token-btn + opacity: 0 + &:hover, &:focus + .add-token-btn + opacity: 1 + background-color: $good + .tile + opacity: 1 + + +// pets (this will all change when pet system is overhauled) +.pet-grid + padding-top: 1.5em + border-top: rgba(0,0,0,0.1) + table + width: 100% + td + padding: 0.5em + width: 25% + &.active-pet + background-color: $bad + outline: 1px solid rgba(0,0,0,0.1) + outline-offset: -1px + &:hover, &:focus + background-color: darken($better, 10%) \ No newline at end of file diff --git a/styles/app/responsive.styl b/styles/app/responsive.styl new file mode 100644 index 0000000000..d79c8dc14e --- /dev/null +++ b/styles/app/responsive.styl @@ -0,0 +1,31 @@ +.grid + padding: 0 1.5em 1.5em 0 + letter-spacing: -4px + +.module + display: inline-block + letter-spacing: normal + vertical-align: top + width: 25% + padding: 0 0 1.5em 1.5em + -moz-box-sizing: border-box + box-sizing: border-box + +// at 960px when 4 columns really starts to break +@media (max-width: 60em) + .module + width: 50% + .task-column + max-height: 18em + overflow-y: scroll + +// at 600px when 2col starts breaking +@media (max-width: 37.5em) + .grid + padding-right: 0 + .module + width: 100% + padding-left: 0 + .task-column + max-height: none + overflow: visible \ No newline at end of file diff --git a/styles/app/scrollbars.styl b/styles/app/scrollbars.styl new file mode 100644 index 0000000000..8b366133bf --- /dev/null +++ b/styles/app/scrollbars.styl @@ -0,0 +1,82 @@ +/* Gmail style scrollbar */ +.task-column::-webkit-scrollbar { + width: 12px +} +.task-column::-webkit-scrollbar-thumb { + border-width: 1px 1px 1px 2px +} +.task-column::-webkit-scrollbar-track { + border-width: 0 +} +.task-column::-webkit-scrollbar { + height: 16px; + overflow: visible; + width: 16px; +} +.task-column::-webkit-scrollbar-button { + height: 0; + width: 0; +} +.task-column::-webkit-scrollbar-track { + background-clip: padding-box; + border: solid transparent; + border-width: 0 0 0 4px; +} +.task-column::-webkit-scrollbar-track:horizontal { + border-width: 4px 0 0 +} +.task-column::-webkit-scrollbar-track:hover { + background-color: rgba(150,150,150,.05); + box-shadow: inset 1px 0 0 rgba(150,150,150,.1); +} +.task-column::-webkit-scrollbar-track:horizontal:hover { + box-shadow: inset 0 1px 0 rgba(150,150,150,.1) +} +.task-column::-webkit-scrollbar-track:active { + background-color: rgba(150,150,150,.05); + box-shadow: inset 1px 0 0 rgba(150,150,150,.14),inset -1px 0 0 rgba(150,150,150,.07); +} +.task-column::-webkit-scrollbar-track:horizontal:active { + box-shadow: inset 0 1px 0 rgba(150,150,150,.14),inset 0 -1px 0 rgba(150,150,150,.07) +} +.task-column::-webkit-scrollbar-thumb { + background-color: rgba(150,150,150,.2); + background-clip: padding-box; + border: solid transparent; + border-width: 1px 1px 1px 6px; + min-height: 28px; + padding: 100px 0 0; + box-shadow: inset 1px 1px 0 rgba(150,150,150,.1),inset 0 -1px 0 rgba(150,150,150,.07); +} +.task-column::-webkit-scrollbar-thumb:horizontal { + border-width: 6px 1px 1px; + padding: 0 0 0 100px; + box-shadow: inset 1px 1px 0 rgba(150,150,150,.1),inset -1px 0 0 rgba(150,150,150,.07); +} +.task-column::-webkit-scrollbar-thumb:hover { + background-color: rgba(150,150,150,.4); + box-shadow: inset 1px 1px 1px rgba(150,150,150,.25); +} +.task-column::-webkit-scrollbar-thumb:active { + background-color: rgba(150,150,150,0.5); + box-shadow: inset 1px 1px 3px rgba(150,150,150,0.35); +} +.task-column::-webkit-scrollbar-track { + border-width: 0 1px 0 6px +} +.task-column::-webkit-scrollbar-track:horizontal { + border-width: 6px 0 1px +} +.task-column::-webkit-scrollbar-track:hover { + background-color: rgba(150,150,150,.035); + box-shadow: inset 1px 1px 0 rgba(150,150,150,.14),inset -1px -1px 0 rgba(150,150,150,.07); +} +.task-column::-webkit-scrollbar-thumb { + border-width: 0 1px 0 6px +} +.task-column::-webkit-scrollbar-thumb:horizontal { + border-width: 6px 0 1px +} +.task-column::-webkit-scrollbar-corner { + background: transparent +} \ No newline at end of file diff --git a/styles/app/shop_sprites.styl b/styles/app/shop_sprites.styl index b3abb056c5..b609105fa1 100644 --- a/styles/app/shop_sprites.styl +++ b/styles/app/shop_sprites.styl @@ -49,6 +49,6 @@ .shop_armor_1 {background-position: -800px 0; width: 40px; height: 40px} .shop_reroll {background-position: -840px 0; width: 40px; height: 40px} .shop_potion {background-position: -880px 0; width: 40px; height: 40px} -.shop_copper {background-position: -920px 0; width: 40px; height: 40px} -.shop_silver {background-position: -960px 0; width: 40px; height: 40px} -.shop_gold {background-position: -1000px 0; width: 40px; height: 40px} +.shop_copper {background-position: -923px -8px; width: 32px; height: 22px} +.shop_silver {background-position: -963px -8px; width: 32px; height: 22px} +.shop_gold {background-position: -1003px -8px; width: 32px; height: 22px} diff --git a/styles/app/tasks.styl b/styles/app/tasks.styl index 6faf19fcd5..e1ae48fa3f 100644 --- a/styles/app/tasks.styl +++ b/styles/app/tasks.styl @@ -1,42 +1,354 @@ -.color-worst pre - background-color: rgb(230, 184, 175) -.color-worse pre - background-color: rgb(244, 204, 204) -.color-bad pre - background-color: rgb(252, 229, 205) -.color-neutral pre - background-color: rgb(255, 242, 204) -.color-good pre - background-color: rgb(217, 234, 211) -.color-better pre - background-color: rgb(208, 224, 227) -.color-best pre - background-color: rgb(201, 218, 248) -.completed pre - background-color: rgb(217, 217, 217) - color: rgb(153, 153, 153) -.reward pre +// 1. Set up color classes +// ======================== + +// color variables +$worst = rgb(230, 184, 175) +$worse = rgb(244, 204, 204) +$bad = rgb(252, 229, 205) +$neutral = rgb(255, 242, 204) +$good = rgb(217, 234, 211) +$better = rgb(208, 224, 227) +$best = rgb(201, 218, 248) +$completed = rgb(217, 217, 217) + +// array of keywords and their associated color vars +$stages = (worst $worst) (worse $worse) (bad $bad) (neutral $neutral) (good $good) (better $better) (best $best) + +// for each color stage, generate a named class w/ the appropriate color +for $stage in $stages + .color-{$stage[0]} + background-color: $stage[1] + .action-yesno label, + .task-action-btn + background-color: darken($stage[1], 30%) + &:hover, &:focus + background-color: darken($stage[1], 40%) + +// completed has to be outside the loop to override the color class +.completed + background-color: $completed + color: #999 + .task-checker label + background-color: darken($completed, 30%) + .task-action-btn + background-color: darken($completed, 30%) + +.reward background-color: white -label.checkbox.inline{ - width: 40px + + +// 2. Columns & Tasks +// =================== + +// main columns +// ------------ +.task-column + padding: 1.5em + background: #f5f5f5 + border: 1px solid #ccc + font-family: 'Lato', sans-serif + + .task-column_title + margin: 0 0 0.5em + padding: 0 + + +// add new task form +// ----------------- +.addtask-form + margin-bottom: 0.75em + outline: 1px solid rgba(0,0,0,0.15) + outline-offset: -1px + background-color: white + // box-shadow: 0 0 3px rgba(0,0,0,0.15) + position: relative + +// the input field +.addtask-field + display: block + width: 100% + input + font-family: 'Lato', sans-serif + border: 0 + outline: 0 + border-radius: 0 + box-shadow: none + background-color: white + height: 3em + padding: 0 0 0 0.5em + width: 100% + &:focus + box-shadow: inset 0 0 3px darken($best, 20%),inset -1px 0 1px darken($best, 30%) + +// the button +.addtask-btn + position: absolute + right: 0 + top: 0 + // important for overriding bootstrap, remove later + width: 1.81818em !important + height: 1.88em + padding: 0 + font-size: 1.61em + line-height: 1.7 + outline: 0 + border: 0 + border-radius: 0 //required to nuke BB-playbook user agent styles + background-color: darken($best, 20%) + opacity: 0.75 + &:hover, + &:focus + opacity: 1 + background-color: darken($best, 20%) + &:active + background-color: darken($best, 30%) + opacity: 1 + + +// an individual task entry +// ------------------------ +.task + overflow: hidden + list-style: none + clear: both + padding: 0 0.5em 0 0.5em + min-height: 3em + line-height: 3 + margin-bottom: 0.75em + outline: 1px solid rgba(0,0,0,0.1) + outline-offset: -1px + &:hover + cursor: move + &:last-child + margin-bottom: 0 + +// task content +.task-text + display: inline + +// when a task is being dragged +.task.ui-sortable-helper { + box-shadow: 0 0 3px rgba(0,0,0,0.15), 0 0 5px rgba(0,0,0,0.1) + transform: scale(1.05) + outline: 1px solid rgba(0,0,0,0.2) } -.todos - .nav-pills > .active > a, .nav-pills > .active > a:hover - color: #005580 - background-color: #DEE5F2 +// primary task commands +// ---------------------- +.task-controls + display: inline + margin-left: -0.5em + margin-right: 0.5em -.dailys - .repeat-days > .btn:not(.active) - background-color: #aaa; - background-image: -moz-linear-gradient(top, #eee, #aaa); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#eee), to(#aaa)); - background-image: -webkit-linear-gradient(top, #eee, #aaa); - background-image: -o-linear-gradient(top, #eee, #aaa); - background-image: linear-gradient(to bottom, #eee, #aaa); - background-repeat: repeat-x; +// plus/minus commands +.task-action-btn + display: inline-block + width: 2.12765em + height: 2.12765em + padding: 0 + font-size: 1.41em + line-height: 2.12765 + text-align: center + color: #222 + vertical-align: top + border-right: 1px solid rgba(0,0,0,0.25) + &:last-child + border: 0 + &:hover, &:focus + color: #222 + text-decoration: none +// checkbox +.task-checker input[type=checkbox] + margin: 0 + padding: 0 + visibility: hidden + +.task-checker label + display: inline-block + width: 3em + height: 3em + margin: 0 + vertical-align: top + cursor: pointer + +// plus/minus checkbox +.action-plusminus + label + margin-right: 1.5em + label:after + font-size: 1.41em + width: 2.12765em + height: 2.12765em + line-height: 2.12765 + text-align: center + display: inline-block + vertical-align top + opacity: 0.3 + background-color: #ddd + outline: 1px solid rgba(0,0,0,0.5) + color: #222 + label[for$="plus"]:after + content: '+' + label[for$="minus"]:after + content: '−' + + &.select-toggle input[type=checkbox]:checked + label:after + opacity: 1 + +// yes/no checkbox +.action-yesno + position: relative + // uncompleted icon + label:after, label:before + // content: '◯' + content: '' + display: block + height: 1.714285714em + width: 1.714285714em + font-size: 1.75em + line-height: 1.714285714em + text-align: center + color: black + opacity: 0.2 + + label:after + content: '▨' + label:before + position: absolute + left: 0 + // hint at completed icon + label:hover:before, + label:focus:before + content: '' + label:hover:after, + label:focus:after + content: '¬' + font-size: 3.2em + line-height: 0.9375em + height: 0.9375em + width: 1.15em + text-align: center + transform: rotate(135deg) + opacity: 0.5 !important + label:active:after + opacity: 0.75 !important + + // completed icon + input[type=checkbox]:checked + label:after + content: '¬' + font-size: 3.2em + line-height: 0.9375em + height: 0.9375em + width: 1.15em + text-align: center + transform: rotate(135deg) + opacity: 0.75 + + +// secondary task commands +// ----------------------- +.task-meta-controls + float: right + margin-left: 0.25em + opacity: 0.25 + a > i + margin-left: 0.125em + .task-notes + margin-left: 0.125em + +.task:hover .task-meta-controls + opacity: 1 + + +// task editing +// ------------ +.task-options + padding: 0 0.5em + margin-top: 1.25em + color: #333 + + .option-group + border-bottom: 1px solid rgba(0,0,0,0.1) + padding: 0 0 1em + margin-bottom: 1em + + .option-title + font-size: 1em + margin: 0 0 0.5em + line-height: 1.5 + border: 0 + padding: 0 + color: #333 + font-style: italic + &:not(.mega):after + content: ':' + &.mega + cursor: pointer + font-style: normal + font-weight: bold + text-decoration: underline + + .option-content + height: 2.5em + width: 100% + margin: 0 0 1em + padding: 0 0 0 0.5em + outline: 0 + border: 1px solid rgba(0,0,0,0.2) + box-shadow: none + border-radius: 0 + font-family: 'Lato', sans-serif //bootstrap override + + textarea.option-content + height: 5em + padding-top: 0.25em + resize: none + margin-bottom: 0 + +// button-group +.task-controls.tile-group + display: block + text-align: center + margin: 0 + +.task-action-btn.tile + border: 0 + font-size: 1.15em + font-weight: 300 + outline: 1px solid rgba(0,0,0,0.2) + outline-offset: -1px + margin: 0 0 0 3px + font-family: 'Lato', sans-serif + text-align: inherit + opacity: 0.5 + width: auto + padding: 0 0.5em + &.active + opacity: 1 + +.tile.spacious + margin: 0.75em 0 + font-size: 1.5em + opacity: 1 +.tile.bright + background-color: lighten($best, 20%) + &:hover, &:focus + background-color: lighten($best, 40%) +.tile.flush + margin-left: 0 + border: 1px solid rgba(0,0,0,0.2) + outline: 0 + line-height: 2em + &:first-child + border-right: 0 + + +// todos ui +// -------- + +// context switching .context-enabled .display-context-dependant, .task-list > li @@ -50,39 +362,19 @@ label.checkbox.inline{ .show-for-uncompleted, .uncompleted display: block -.help-icon - float:right; -.task:hover - cursor: move - -li:hover .task-meta-controls .hover-show - display: inline - -.task - list-style:none - - pre - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif - font-size: 13px - line-height: 18px - color: black - word-break: normal - - .task-meta-controls - float:right - i - margin-left:5px - - .task-controls,.task-text - display:inline - margin-right:10px - - .task-meta-controls .hover-show - display: none; - -.vote-up, .vote-down - text-decoration:none; - -.new-task-form - margin-bottom:5px; \ No newline at end of file +// nav tabs +.nav-tabs + margin-top: 1.5em +.nav-tabs > li + &.active > a + color: #333 + &:hover + margin-top: 1px + > a + border-radius: 0 !important + margin-top: 1px + color: #333 + &:hover + border-top: 0 + margin-top: 2px diff --git a/test/api.mocha.coffee b/test/api.mocha.coffee new file mode 100644 index 0000000000..e243322bd9 --- /dev/null +++ b/test/api.mocha.coffee @@ -0,0 +1,273 @@ +_ = require 'underscore' +request = require 'superagent' +expect = require 'expect.js' +require 'coffee-script' + +conf = require("nconf") +conf.argv().env().file({file: __dirname + '../config.json'}).defaults + +# Override normal ENV values with nconf ENV values (ENV values are used the same way without nconf) +#FIXME can't get nconf file above to load... +process.env.BASE_URL = conf.get("BASE_URL") +process.env.FACEBOOK_KEY = conf.get("FACEBOOK_KEY") +process.env.FACEBOOK_SECRET = conf.get("FACEBOOK_SECRET") +process.env.NODE_DB_URI = 'mongodb://localhost/habitrpg' + +## monkey-patch expect.js for better diffs on mocha +## see: https://github.com/LearnBoost/expect.js/pull/34 + +origBe = expect.Assertion::be +expect.Assertion::be = expect.Assertion::equal = (obj) -> + @_expected = obj + origBe.call this, obj + +# Custom modules +character = require '../src/app/character' + +###### Helpers & Variables ###### + +model = null +uuid = null +taskPath = null +baseURL = 'http://localhost:1337/api/v1' + +###### Specs ###### + +describe 'API', -> + server = null + store = null + model = null + user = null + uid = null + + before (done) -> + server = require '../src/server' + server.listen '1337', '0.0.0.0' + server.on 'listening', (data) -> + store = server.habitStore + #store.flush() + model = store.createModel() + model.set '_userId', uid = model.id() + user = character.newUserObject() + user.apiToken = model.id() + model.session = {userId:uid} + model.set "users.#{uid}", user + delete model.session + # Crappy hack to let server start before tests run + setTimeout done, 2000 + + describe 'Without token or user id', -> + + it '/api/v1/status', (done) -> + request.get("#{baseURL}/status") + .set('Accept', 'application/json') + .end (res) -> + expect(res.statusCode).to.be 200 + expect(res.body.status).to.be 'up' + done() + + it '/api/v1/user', (done) -> + request.get("#{baseURL}/user") + .set('Accept', 'application/json') + .end (res) -> + expect(res.statusCode).to.be 401 + expect(res.body.err).to.be 'You must include a token and uid (user id) in your request' + done() + + describe 'With token and user id', -> + params = null + currentUser = null + + before -> + user = model.at("users.#{uid}") + currentUser = user.get() + params = + title: 'Title' + text: 'Text' + type: 'habit' + + beforeEach -> + currentUser = user.get() + + it 'GET /api/v1/user', (done) -> + request.get("#{baseURL}/user") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + expect(res.body.id).not.to.be.empty() + self = _.clone(currentUser) + delete self.apiToken + self.stats.toNextLevel = 150 + self.stats.maxHealth = 50 + + expect(res.body).to.eql self + done() + + it 'GET /api/v1/user/task/:id', (done) -> + tid = _.pluck(currentUser.tasks, 'id')[0] + request.get("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + expect(res.body).to.eql currentUser.tasks[tid] + done() + + it 'POST /api/v1/user/task', (done) -> + request.post("#{baseURL}/user/task") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send(params) + .end (res) -> + query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken) + query.fetch (err, user) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 201 + expect(res.body.id).not.to.be.empty() + # Ensure that user owns the newly created object + expect(user.get().tasks[res.body.id]).to.be.an('object') + done() + + it 'POST /api/v1/user/task (without type)', (done) -> + request.post("#{baseURL}/user/task") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send({}) + .end (res) -> + expect(res.body.err).to.be 'type must be habit, todo, daily, or reward' + expect(res.statusCode).to.be 400 + done() + + it 'POST /api/v1/user/task (only type)', (done) -> + request.post("#{baseURL}/user/task") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send(type: 'habit') + .end (res) -> + query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken) + query.fetch (err, user) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 201 + expect(res.body.id).not.to.be.empty() + # Ensure that user owns the newly created object + expect(user.get().tasks[res.body.id]).to.be.an('object') + done() + + it 'PUT /api/v1/user/task/:id', (done) -> + tid = _.pluck(currentUser.tasks, 'id')[0] + request.put("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send(text: 'bye') + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + currentUser.tasks[tid].text = 'bye' + expect(res.body).to.eql currentUser.tasks[tid] + done() + + it 'PUT /api/v1/user/task/:id (shouldnt update type)', (done) -> + tid = _.pluck(currentUser.tasks, 'id')[1] + type = if currentUser.tasks[tid].type is 'habit' then 'daily' else 'habit' + request.put("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send(type: type, text: 'fishman') + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + currentUser.tasks[tid].text = 'fishman' + expect(res.body).to.eql currentUser.tasks[tid] + done() + + it 'PUT /api/v1/user/task/:id (update notes)', (done) -> + tid = _.pluck(currentUser.tasks, 'id')[2] + request.put("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .send(text: 'hi',notes:'foobar matey') + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + currentUser.tasks[tid].text = 'hi' + currentUser.tasks[tid].notes = 'foobar matey' + expect(res.body).to.eql currentUser.tasks[tid] + done() + + it 'GET /api/v1/user/tasks', (done) -> + request.get("#{baseURL}/user/tasks") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .end (res) -> + query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken) + query.fetch (err, user) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + model.ref '_user', user + tasks = [] + for type in ['habit','todo','daily','reward'] + model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" + tasks = tasks.concat model.get("_#{type}List") + # Ensure that user owns the tasks + expect(res.body.length).to.equal tasks.length + # Ensure that the two sets are equal + expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0 + done() + + it 'GET /api/v1/user/tasks (todos)', (done) -> + request.get("#{baseURL}/user/tasks") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .query(type:'todo') + .end (res) -> + query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken) + query.fetch (err, user) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 200 + model.ref '_user', user + model.refList "_todoList", "_user.tasks", "_user.todoIds" + tasks = model.get("_todoList") + # Ensure that user owns the tasks + expect(res.body.length).to.equal tasks.length + # Ensure that the two sets are equal + expect(_.difference(_.pluck(res.body,'id'), _.pluck(tasks,'id')).length).to.equal 0 + done() + + it 'DELETE /api/v1/user/task/:id', (done) -> + tid = currentUser.habitIds[2] + request.del("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .end (res) -> + expect(res.body.err).to.be undefined + expect(res.statusCode).to.be 204 + query = model.query('users').withIdAndToken(currentUser.id, currentUser.apiToken) + query.fetch (err, user) -> + expect(user.get('habitIds').indexOf(tid)).to.be -1 + expect(user.get("tasks.#{tid}")).to.be undefined + done() + + it 'DELETE /api/v1/user/task/:id (no task found)', (done) -> + tid = "adsfasdfjunkshouldntbeatask" + request.del("#{baseURL}/user/task/#{tid}") + .set('Accept', 'application/json') + .set('X-API-User', currentUser.id) + .set('X-API-Key', currentUser.apiToken) + .end (res) -> + expect(res.statusCode).to.be 400 + expect(res.body.err).to.be 'No task found.' + done() diff --git a/test/casper/rest.casper.coffee b/test/casper/rest.casper.coffee index 18aaf3afde..767d93489e 100644 --- a/test/casper/rest.casper.coffee +++ b/test/casper/rest.casper.coffee @@ -12,8 +12,8 @@ casper.start "#{url}/?play=1", -> @fill 'form#derby-auth-register', username: user1.id email: "{user1.id}@gmail.com" - 'email-confirmation': "{user1.id}@gmail.com" password: 'habitrpg123' + 'password-confirmation': "habitrpg123" , true casper.thenOpen "#{url}/logout" casper.thenOpen "#{url}/?play=1", -> diff --git a/test/mocha.opts b/test/mocha.opts index d6ef947989..72889931b5 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,6 +1,7 @@ --colors --reporter spec ---timeout 1200 +--timeout 2800 +--ignore-leaks --growl --debug --compilers coffee:coffee-script diff --git a/test/user.mocha.coffee b/test/user.mocha.coffee index 33b1917014..33ab7ee431 100644 --- a/test/user.mocha.coffee +++ b/test/user.mocha.coffee @@ -1,14 +1,15 @@ {expect} = require 'derby/node_modules/racer/test/util' {BrowserModel: Model} = require 'derby/node_modules/racer/test/util/model' derby = require 'derby' -clone = require 'clone' +lodash = require 'lodash' _ = require 'underscore' moment = require 'moment' # Custom modules scoring = require '../src/app/scoring' -schema = require '../src/app/schema' - +schema = require '../src/app/character' +helpers = require '../src/app/helpers' + ###### Helpers & Variables ###### model = null @@ -19,11 +20,11 @@ taskPath = null # Otherwise, using model.get(path) will give the same object before as after pathSnapshots = (paths) -> if _.isString(paths) - return clone(model.get(paths)) - _.map paths, (path) -> clone(model.get(path)) + return lodash.cloneDeep(model.get(paths)) + _.map paths, (path) -> lodash.cloneDeep(model.get(path)) statsTask = -> pathSnapshots(['_user.stats', taskPath]) # quick snapshot of user.stats & task -cleanUserObj = -> +cleanUserObj = -> userObj = schema.newUserObject() userObj.tasks = {} userObj.habitIds = [] @@ -42,14 +43,14 @@ freshTask = (taskObj) -> model.refList "_#{type}List", "_user.tasks", "_user.#{type}Ids" [taskObj.id, taskObj.value] = [uuid, 0] model.at("_#{type}List").push taskObj - + ### Helper function to determine if stats updates are numerically correct based on scoring @direction: 'up' or 'down' -@options: The user stats modifiers and times to run, defaults to {times:1, modifiers:{lvl:1, weapon:0, armor:0}} +@options: The user stats modifiers and times to run, defaults to {times:1, modifiers:{lvl:1, weapon:0, armor:0}} ### modificationsLookup = (direction, options = {}) -> - merged = _.defaults options, {times:1, lvl:1, weapon:0, armor:0} + merged = _.defaults options, {times:1, lvl:1, weapon:0, armor:0} {times, lvl, armor, weapon} = merged userObj = cleanUserObj() value = 0 @@ -64,12 +65,24 @@ modificationsLookup = (direction, options = {}) -> loss = scoring.hpModifier(delta, options) userObj.stats.hp += loss return {user:userObj, value:value} - -###### Specs ###### + +###### Specs ###### + +describe 'Cron', -> + it 'calculates day differences with dayStart properly', -> + dayStart = 4 + yesterday = moment().subtract('d', 1).add('h', dayStart) + now = moment().startOf('day').add('h', dayStart-1) #today + console.log {yesterday: yesterday.format('MM/DD HH:00'), now: now.format('MM/DD HH:00')} + console.log {diff: Math.abs(moment(yesterday).diff(moment(now), 'days'))} + expect(helpers.daysBetween(yesterday, now, dayStart)).to.eql 0 + now = moment().startOf('day').add('h', dayStart) + console.log {now: now.format('MM/DD HH:00')} + expect(helpers.daysBetween(yesterday, now, dayStart)).to.eql 1 describe 'User', -> model = null - + before -> model = new Model model.set '_user', schema.newUserObject() @@ -85,23 +98,23 @@ describe 'User', -> expect(_.size(user.dailyIds)).to.eql 3 expect(_.size(user.todoIds)).to.eql 1 expect(_.size(user.rewardIds)).to.eql 2 - - ##### Habits ##### + + ##### Habits ##### describe 'Tasks', -> - - beforeEach -> + + beforeEach -> resetUser() - + describe 'Habits', -> - - beforeEach -> + + beforeEach -> freshTask {type: 'habit', text: 'Habit', up: true, down: true} - + it 'created the habit', -> task = model.get(taskPath) expect(task.text).to.eql 'Habit' expect(task.value).to.eql 0 - + it 'test a few scoring numbers (this will change if constants / formulae change)', -> {user} = modificationsLookup('down') expect(user.stats.hp).to.eql 49 @@ -113,10 +126,10 @@ describe 'User', -> expect(user.stats.money).to.eql 1 {user} = modificationsLookup('up', {times:5}) expect(user.stats.exp).to.be.within(4,5) - + it 'made proper modifications when down-scored', -> ## Trial 1 - + shouldBe = modificationsLookup('down') scoring.score(uuid,'down') [stats, task] = statsTask() @@ -130,14 +143,14 @@ describe 'User', -> [stats, task] = statsTask() expect(stats.hp).to.be.eql shouldBe.user.stats.hp expect(task.value).to.eql shouldBe.value - + it 'made proper modifications when up-scored', -> # Up-score the habit [statsBefore, taskBefore] = statsTask() scoring.score(uuid, 'up') [statsAfter, taskAfter] = statsTask() - - # User should have gained Exp, GP + + # User should have gained Exp, GP expect(statsAfter.exp).to.be.greaterThan statsBefore.exp expect(statsAfter.money).to.be.greaterThan statsBefore.money # HP should not change @@ -145,9 +158,9 @@ describe 'User', -> # Task should have lost value expect(taskBefore.value).to.eql 0 expect(taskAfter.value).to.be.greaterThan taskBefore.value - + ## Trial 2 - taskBefore = pathSnapshots(taskPath) + taskBefore = pathSnapshots(taskPath) scoring.score(uuid, 'up') taskAfter = pathSnapshots(taskPath) # Should have lost in value @@ -155,54 +168,54 @@ describe 'User', -> # And lost more than trial 1 diff = Math.abs(taskAfter.value) - Math.abs(taskBefore.value) expect(diff).to.be.lessThan 1 - - it 'makes history entry for habit' + + it 'makes history entry for habit' it 'makes proper modifications each time when clicking + / - in rapid succession' # saw an issue here once, so test that it wasn't a fluke - + it 'should not modify certain attributes given certain conditions' # non up+down habits # what else? - + it 'should show "undo" notification if user unchecks completed daily' - - - describe 'Lvl & Items', -> - - beforeEach -> + + + describe 'Lvl & Items', -> + + beforeEach -> freshTask {type: 'habit', text: 'Habit', up: true, down: true} - + it 'modified damage based on lvl & armor' it 'modified exp/gp based on lvl & weapon' it 'always decreases hp with damage, regardless of stats/items' it 'always increases exp/gp with gain, regardless of stats/items' - + describe 'Dailies', -> - + beforeEach -> freshTask {type: 'daily', text: 'Daily', completed: false} - + 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 dailys properly when they have repeat dates' - + runCron = (times, pass=1) -> # Set lastCron to days ago today = new moment() ago = new moment().subtract('days',times) model.set '_user.lastCron', ago.toDate() # Run run - scoring.cron() + scoring.cron() [stats, task] = statsTask() - + # Should have updated cron to today lastCron = moment(model.get('_user.lastCron')) expect(today.diff(lastCron, 'days')).to.eql 0 - + shouldBe = modificationsLookup('down', {times:times*pass}) # Should have updated points properly expect(Math.round(stats.hp)).to.be.eql Math.round(shouldBe.user.stats.hp) @@ -215,10 +228,10 @@ describe 'User', -> runCron(5) runCron(5, 2) - + #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 - + describe 'Todos', -> describe 'Cron', -> it 'calls cron asyncronously' @@ -230,11 +243,11 @@ describe 'User', -> # 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', -> - + #### 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 + # sortable, stripe, etc #TODO refactor as user->habits, user->dailys, user->todos, user->rewards \ No newline at end of file diff --git a/views/app/alerts.html b/views/app/alerts.html index c76e61c010..55ad0571e8 100644 --- a/views/app/alerts.html +++ b/views/app/alerts.html @@ -1,6 +1,32 @@ {#if _flash.error} -